feat(onboarding): first-run home tour (spotlight coach marks) (#528)

* style(tour): align tour engine + Shepherd bubbles to the v3 fb-* palette

The tour/help engine shipped its own indigo/blue dark palette (#181830 / #4080e0)
that predates the v3 fee[dB]ack tokens, and the spotlight bubbles themselves used
the vendored Shepherd LIGHT default (white card, black text) — both clashed with
the navy/sky v3 UI behind them.

- Recolor the "?" menu button, popover and first-visit toast to the fb-* tokens
  (card #1e293b, primary #0ea5e9, border #334155, text #f8fafc/#94a3b8, gold
  #e8c040 unchanged).
- Add a dark .shepherd-* override block (loads after the vendored shepherd.css,
  which is left pristine for upgrades): dark bubble + arrow, fb-primary Next/Done
  button, slate secondary button, fb text scale, and bump the modal dim to 0.6 to
  match the onboarding overlay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tour-engine): let client/core tours register into the consolidated menu

The tour engine only listed server-discovered plugins (those with a tour.json,
populated from /api/plugins) in the "?" menu, and always prompted unseen relevant
tours via the toast + button pulse. Generalize register() so a core/client-owned
tour can participate:
- `name` registers the tour into the menu catalog (_tourPlugins) so it shows in
  the "?" menu even without a server plugin; never clobbers a real plugin entry.
- `autoPrompt:false` opts the tour OUT of the unseen toast + pulse (for tours
  driven programmatically by their owner), while still listing + running on
  demand. _unseenRelevant honours it.
Both options are additive and default to the prior behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(v3): add stable tour anchors to home cards + instrument badge

Give the first-run home tour stable spotlight targets: #v3-hero on the hero
panel and data-tour="continue" on the three continue/pick/browse card variants
(dashboard.js), and #v3-instrument-wrap on the topbar instrument selector
(badges.js, mirroring the existing #v3-tuner-wrap). The other targets (audio
routing, tuner, profile, sidebar nav) already had stable ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(onboarding): first-run home tour (spotlight coach marks)

After a genuine onboarding completion, dim the home page and spotlight one card
at a time with an explanatory bubble + Next, reusing the shared tour engine
(Shepherd). 7 stops: Hero/Start Playing → Continue/Pick → Instrument selector →
Tuner → Audio Routing → Profile → Sidebar nav. Auto-runs once; replayable
forever from the "?" tour menu as "Welcome tour".

- New static/v3/onboarding-tour.js: registers the spotlight tour (screens:
  ['v3-home'], name "Welcome tour", autoPrompt:false) and exposes startFirstRun(),
  gated on the engine's seen/dismissed state so it never repeats; loaded after
  tour-engine.js + dashboard.js.
- profile.js finish(): trigger startFirstRun() only on a real onboarding
  completion (!editing) — a later profile edit must not relaunch it.

Verified headlessly (native core + Playwright): all 7 anchors resolve, the
spotlight advances one bubble at a time in the v3 dark theme, completion marks
seen, startFirstRun is once-only, and the "?" menu lists "Welcome tour".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(capabilities): clear the handler timeout timer once the race settles

Codex round-6: _withTimeout raced the handler promise against a bare setTimeout
but never cleared it, so a handler that resolves first leaves the timer alive
until it fires. Harmless at 250ms, but the new 15s MIDI permission-command
overrides (discover/open-source) kept the event loop alive ~15s after every
successful call (and the test process hung that long) and could accumulate
delayed callbacks across repeated scans. Capture the timer and clearTimeout it in
a .finally on the race. (Domain/capabilities tests now finish in ~0.1s, not 15s.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi-input): give the built-in Web-MIDI provider a distinct participant id

Codex round-7: the built-in Web-MIDI provider registered with participantId
'core.midi-input' — the same id as the domain owner. unregisterProvider()
unregisters the provider's participant, so a provider swap/hot-reload would tear
down the domain OWNER too, leaving midi-input with no owner for later commands.
Register the provider as 'core.midi-input.web-midi'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(onboarding): don't start the home tour when launching the diagnostic

Codex round-7: on the final onboarding step, "Play it now" calls finish() (which
started the home tour) and THEN playSong(target). startFirstRun() navigated to
v3-home and scheduled the tour, then playSong switched to the player — so the
tour spotlighted hidden home elements / stole focus from the diagnostic. Gate the
tour on a launchingSong flag (passed by the "Play it now" path); the Skip path
stays on home, so the tour still runs there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-19 16:32:33 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent fb06e288e1
commit c7fb074111
9 changed files with 293 additions and 50 deletions
+12 -9
View File
@@ -816,15 +816,18 @@
} }
function _withTimeout(promise, timeoutMs, participant) { function _withTimeout(promise, timeoutMs, participant) {
return Promise.race([ // Capture + clear the timer once the race settles — otherwise a handler
promise, // that resolves first leaves a live setTimeout (up to timeoutMs) that
new Promise(resolve => { // keeps the event loop alive and, for long overrides (MIDI permission
setTimeout(() => resolve({ // commands at 15s), accumulates delayed callbacks across repeated calls.
outcome: 'failed', let timer;
reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`, const timeout = new Promise(resolve => {
}), timeoutMs); timer = setTimeout(() => resolve({
}), outcome: 'failed',
]); reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`,
}), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
} }
function _normalizeDecision(participant, result) { function _normalizeDecision(participant, result) {
+5 -1
View File
@@ -371,7 +371,11 @@
_registerProvider({ _registerProvider({
providerId: 'web-midi', providerId: 'web-midi',
label: 'Web MIDI', label: 'Web MIDI',
participantId: 'core.midi-input', // Distinct from the domain owner's participant id ('core.midi-input').
// unregisterProvider() unregisters the provider's participant, so
// sharing the owner's id would tear the whole domain's owner down on
// a provider swap/hot-reload, leaving midi-input with no owner.
participantId: 'core.midi-input.web-midi',
enumerate: async () => { enumerate: async () => {
access = await navigator.requestMIDIAccess({ sysex: false }); access = await navigator.requestMIDIAccess({ sysex: false });
try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ } try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ }
+115 -32
View File
@@ -1,7 +1,9 @@
/* ── Consolidated tour menu — one floating button + popover for all tours ── */ /* ── Consolidated tour menu — one floating button + popover for all tours ── */
/* Palette aligned with the app's dark theme: #4080e0 accent, #e8c040 gold, /* Palette aligned with the v3 fee[dB]ack design tokens (tailwind.config.js):
#181830 dark background, #cbd5e1 / #94a3b8 / #64748b text scale. Per fb-card #1e293b surfaces, fb-cardMuted #0b1220 wells, fb-primary #0ea5e9
CLAUDE.md → Frontend Conventions. */ accent, fb-border #334155 hairlines, fb-text #f8fafc / fb-textDim #94a3b8
text, fb-gold #e8c040 badge. Plain CSS (no Tailwind classes) so it needs no
stylesheet rebuild. Per CLAUDE.md → Frontend Conventions. */
.slopsmith-tour-menu-btn { .slopsmith-tour-menu-btn {
position: fixed; position: fixed;
@@ -14,20 +16,20 @@
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
background: #181830; background: #1e293b;
border: 1.5px solid #4080e0; border: 1.5px solid #0ea5e9;
color: #cbd5e1; color: #f8fafc;
font-size: 15px; font-size: 15px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
box-shadow: 0 0 6px #4080e066; box-shadow: 0 0 6px #0ea5e966;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: box-shadow 0.2s, transform 0.15s; transition: box-shadow 0.2s, transform 0.15s;
} }
.slopsmith-tour-menu-btn:hover { .slopsmith-tour-menu-btn:hover {
box-shadow: 0 0 12px #4080e0aa; box-shadow: 0 0 12px #0ea5e9aa;
transform: translateY(-1px); transform: translateY(-1px);
} }
.slopsmith-tour-menu-btn.has-unseen { .slopsmith-tour-menu-btn.has-unseen {
@@ -41,12 +43,12 @@
width: 10px; width: 10px;
height: 10px; height: 10px;
background: #e8c040; background: #e8c040;
border: 2px solid #181830; border: 2px solid #1e293b;
border-radius: 50%; border-radius: 50%;
} }
@keyframes tour-pulse { @keyframes tour-pulse {
0%, 100% { box-shadow: 0 0 6px #4080e066; } 0%, 100% { box-shadow: 0 0 6px #0ea5e966; }
50% { box-shadow: 0 0 16px #4080e0cc; } 50% { box-shadow: 0 0 16px #0ea5e9cc; }
} }
.slopsmith-tour-menu-popover { .slopsmith-tour-menu-popover {
@@ -62,12 +64,12 @@
room for the trigger button + its bottom inset. */ room for the trigger button + its bottom inset. */
max-height: calc(100vh - 80px); max-height: calc(100vh - 80px);
overflow-y: auto; overflow-y: auto;
background: #181830; background: #1e293b;
border: 1px solid #4080e044; border: 1px solid #0ea5e944;
border-radius: 8px; border-radius: 8px;
padding: 6px; padding: 6px;
font-size: 13px; font-size: 13px;
color: #cbd5e1; color: #f8fafc;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
} }
.slopsmith-tour-menu-popover .tour-menu-header { .slopsmith-tour-menu-popover .tour-menu-header {
@@ -75,13 +77,13 @@
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #64748b; color: #94a3b8;
border-bottom: 1px solid #1e1e3a; border-bottom: 1px solid #334155;
margin-bottom: 4px; margin-bottom: 4px;
} }
.slopsmith-tour-menu-popover .tour-menu-empty { .slopsmith-tour-menu-popover .tour-menu-empty {
padding: 10px; padding: 10px;
color: #64748b; color: #94a3b8;
font-style: italic; font-style: italic;
text-align: center; text-align: center;
} }
@@ -95,23 +97,23 @@
background: transparent; background: transparent;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
color: #cbd5e1; color: #f8fafc;
font-size: 13px; font-size: 13px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background 0.12s;
} }
.slopsmith-tour-menu-popover .tour-menu-item:hover { .slopsmith-tour-menu-popover .tour-menu-item:hover {
background: #1a1a30; background: #334155;
color: #fff; color: #f8fafc;
} }
/* Keyboard focus gets an explicit ring instead of relying on the hover /* Keyboard focus gets an explicit ring instead of relying on the hover
background — matches the :focus-visible treatment elsewhere in the background — matches the :focus-visible treatment elsewhere in the
app (style.css). Pointer focus is left alone. */ app (style.css). Pointer focus is left alone. */
.slopsmith-tour-menu-popover .tour-menu-item:focus-visible { .slopsmith-tour-menu-popover .tour-menu-item:focus-visible {
background: #1a1a30; background: #334155;
color: #fff; color: #f8fafc;
outline: 2px solid #4080e0; outline: 2px solid #0ea5e9;
outline-offset: -2px; outline-offset: -2px;
} }
.slopsmith-tour-menu-popover .tour-menu-item-label { .slopsmith-tour-menu-popover .tour-menu-item-label {
@@ -127,11 +129,11 @@
} }
.slopsmith-tour-menu-popover .tour-menu-item-status.is-new { .slopsmith-tour-menu-popover .tour-menu-item-status.is-new {
background: #e8c040; background: #e8c040;
color: #181830; color: #0f172a;
} }
.slopsmith-tour-menu-popover .tour-menu-item-status.is-seen { .slopsmith-tour-menu-popover .tour-menu-item-status.is-seen {
background: #1e1e3a; background: #0b1220;
color: #64748b; color: #94a3b8;
} }
/* ── First-visit toast — anchored above the menu button ── */ /* ── First-visit toast — anchored above the menu button ── */
@@ -141,12 +143,12 @@
bottom: 56px; bottom: 56px;
right: 12px; right: 12px;
z-index: 202; z-index: 202;
background: #181830; background: #1e293b;
border: 1px solid #4080e044; border: 1px solid #0ea5e944;
border-radius: 8px; border-radius: 8px;
padding: 10px 14px; padding: 10px 14px;
font-size: 13px; font-size: 13px;
color: #cbd5e1; color: #f8fafc;
max-width: 240px; max-width: 240px;
line-height: 1.4; line-height: 1.4;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
@@ -159,7 +161,7 @@
.slopsmith-tour-prompt .tour-prompt-more { .slopsmith-tour-prompt .tour-prompt-more {
margin-top: 4px; margin-top: 4px;
font-size: 11px; font-size: 11px;
color: #64748b; color: #94a3b8;
} }
.slopsmith-tour-prompt .tour-prompt-buttons { .slopsmith-tour-prompt .tour-prompt-buttons {
display: flex; display: flex;
@@ -178,11 +180,92 @@
opacity: 0.85; opacity: 0.85;
} }
.slopsmith-tour-prompt button[data-action="start"] { .slopsmith-tour-prompt button[data-action="start"] {
background: #4080e0; background: #0ea5e9;
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
} }
.slopsmith-tour-prompt button[data-action="dismiss"] { .slopsmith-tour-prompt button[data-action="dismiss"] {
background: #1e1e3a; background: #334155;
color: #94a3b8; color: #94a3b8;
} }
/* ── Shepherd bubble theme — override the vendored light default ──────────── */
/* The vendored static/vendor/shepherd.css ships Shepherd's stock LIGHT theme
(white bubble, black text, blue buttons), which clashes with the dark v3 UI.
These overrides load after it (index.html order) and recolor the spotlight
bubbles to the fb-* tokens. The vendored file is left untouched so it stays
upgradable. */
.shepherd-element {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55);
max-width: 360px;
}
.shepherd-content {
background: #1e293b;
border-radius: 10px;
}
.shepherd-text {
color: #cbd5e1;
font-size: 0.9rem;
line-height: 1.45;
padding: 0.85em 0.9em;
}
.shepherd-title {
color: #f8fafc;
font-size: 0.95rem;
font-weight: 700;
}
/* Title row: drop the light-grey header fill the stock theme paints behind a
titled step, so the header blends into the card. */
.shepherd-has-title .shepherd-content .shepherd-header {
background: transparent;
padding: 0.85em 0.9em 0;
}
/* Arrow must match the bubble surface (stock paints it white, and grey behind
a bottom-placed titled step). */
.shepherd-arrow:before {
background: #1e293b;
}
.shepherd-element.shepherd-has-title[data-popper-placement^="bottom"] > .shepherd-arrow:before {
background-color: #1e293b;
}
.shepherd-footer {
padding: 0 0.75rem 0.75rem;
}
/* Primary button (Next / Done) → fb-primary. */
.shepherd-button {
background: #0ea5e9;
color: #fff;
border-radius: 6px;
font-weight: 600;
padding: 0.4rem 1.1rem;
}
.shepherd-button:not(:disabled):hover {
background: #38bdf8;
color: #fff;
}
/* Secondary button (Back / Skip) → muted slate. */
.shepherd-button.shepherd-button-secondary {
background: #334155;
color: #f8fafc;
}
.shepherd-button.shepherd-button-secondary:not(:disabled):hover {
background: #475569;
color: #f8fafc;
}
.shepherd-cancel-icon {
color: #94a3b8;
}
.shepherd-cancel-icon:hover,
.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover {
color: #f8fafc;
}
.shepherd-has-title .shepherd-content .shepherd-cancel-icon {
color: #94a3b8;
}
/* Dim the page a touch more, matching the onboarding overlay (bg-black/60). */
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
opacity: 0.6;
}
+24 -1
View File
@@ -127,7 +127,13 @@
} }
function _unseenRelevant(screenId) { function _unseenRelevant(screenId) {
return _relevantPlugins(screenId).filter(p => !hasSeen(p.id) && !hasDismissed(p.id)); // 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 ──────────────────────────────────────────────────────────── // ── Menu UI ────────────────────────────────────────────────────────────
@@ -615,7 +621,24 @@
onStart: opts.onStart || null, onStart: opts.onStart || null,
onComplete: opts.onComplete || null, onComplete: opts.onComplete || null,
screens: Array.isArray(opts.screens) ? opts.screens.slice() : null, screens: Array.isArray(opts.screens) ? opts.screens.slice() : null,
// autoPrompt:false opts the tour OUT of the unseen toast + button
// pulse (it's driven programmatically by its owner, e.g. the
// first-run home tour started from onboarding). It still lists in the
// menu and runs via start(). Defaults to the legacy always-prompt.
autoPrompt: opts.autoPrompt !== false,
}; };
// A `name` registers a CLIENT/CORE-owned tour — one that isn't a
// server-discovered plugin with a tour.json — into the consolidated menu
// catalog so it appears in the "?" menu. Never clobber a richer entry the
// /api/plugins pass already supplied for a real plugin of the same id.
if (opts.name && !_tourPlugins[pluginId]) {
_tourPlugins[pluginId] = {
id: pluginId,
name: opts.name,
has_screen: true,
is_viz: false,
};
}
// If the override changes the relevance for the current screen, refresh. // If the override changes the relevance for the current screen, refresh.
_updateMenuVisibility(); _updateMenuVisibility();
} }
+1 -1
View File
@@ -291,7 +291,7 @@
const host = document.getElementById('v3-badge-instrument'); const host = document.getElementById('v3-badge-instrument');
if (!host) return; if (!host) return;
host.innerHTML = host.innerHTML =
'<div class="relative">' + '<div id="v3-instrument-wrap" class="relative">' +
'<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + tuningLabel()) + '" ' + '<button type="button" data-inst-toggle title="Instrument: ' + esc(settings.string_count + '-str ' + tuningLabel()) + '" ' +
'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-2 hover:ring-1 hover:ring-fb-primary/40 transition">' + 'class="bg-fb-card border border-fb-border/50 rounded-2xl h-[92px] w-16 flex flex-col items-center justify-center gap-2 hover:ring-1 hover:ring-fb-primary/40 transition">' +
guitarIcon + guitarIcon +
+4 -4
View File
@@ -134,7 +134,7 @@
const bars = Array.from({ length: segs }, (_, i) => const bars = Array.from({ length: segs }, (_, i) =>
'<span class="flex-1 h-1.5 rounded-full ' + (i < filled ? 'bg-fb-primary' : 'bg-gray-500/40') + '"></span>').join(''); '<span class="flex-1 h-1.5 rounded-full ' + (i < filled ? 'bg-fb-primary' : 'bg-gray-500/40') + '"></span>').join('');
continueCard = continueCard =
'<button id="v3-continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' + '<button id="v3-continue" data-tour="continue" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(cont.art_url, 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') + songArt(cont.art_url, 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' + '<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(cont.tuning_name, 'absolute top-3 right-3') + tuningChip(cont.tuning_name, 'absolute top-3 right-3') +
@@ -146,7 +146,7 @@
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>'; '<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else if (pick) { } else if (pick) {
continueCard = continueCard =
'<button id="v3-pick" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' + '<button id="v3-pick" data-tour="continue" data-fn="' + esc(pick.filename) + '" class="group relative text-left rounded-xl overflow-hidden border border-fb-border/50 bg-fb-card aspect-square self-start flex flex-col justify-end">' +
songArt(libArtUrl(pick), 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') + songArt(libArtUrl(pick), 'absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-70 transition') +
'<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' + '<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent"></div>' +
tuningChip(pick.tuning_name, 'absolute top-3 right-3') + tuningChip(pick.tuning_name, 'absolute top-3 right-3') +
@@ -157,7 +157,7 @@
'<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>'; '<span class="absolute top-3 left-3 text-fb-text/80 group-hover:text-fb-text">▶</span></button>';
} else { } else {
continueCard = continueCard =
'<div class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' + '<div data-tour="continue" class="rounded-xl border border-fb-border/50 bg-fb-card/60 aspect-square self-start flex flex-col items-center justify-center text-center p-4">' +
'<div class="text-fb-textDim text-sm mb-3">Pick a song to get started</div>' + '<div class="text-fb-textDim text-sm mb-3">Pick a song to get started</div>' +
'<button id="v3-continue-pick" class="bg-fb-card hover:bg-fb-card/70 border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">Browse library</button></div>'; '<button id="v3-continue-pick" class="bg-fb-card hover:bg-fb-card/70 border border-fb-border/50 text-fb-text text-sm px-4 py-2 rounded-md">Browse library</button></div>';
} }
@@ -188,7 +188,7 @@
'<a href="' + esc(changelogUrl) + '" target="_blank" rel="noopener" class="text-fb-primary hover:text-fb-primaryHi">Patch Notes for ' + esc(ver) + '</a>?</p>' : '') + '<a href="' + esc(changelogUrl) + '" target="_blank" rel="noopener" class="text-fb-primary hover:text-fb-primaryHi">Patch Notes for ' + esc(ver) + '</a>?</p>' : '') +
// Featured grid: hero + continue // Featured grid: hero + continue
'<div class="grid lg:grid-cols-3 gap-6 mt-6">' + '<div class="grid lg:grid-cols-3 gap-6 mt-6">' +
'<div class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' + '<div id="v3-hero" class="lg:col-span-2 relative rounded-xl overflow-hidden min-h-[480px] flex items-center bg-fb-bg">' +
// Hero artwork (neon note-highway), right-anchored. Placeholder // Hero artwork (neon note-highway), right-anchored. Placeholder
// cropped from the design mock — swap static/v3/brand/hero.png for // cropped from the design mock — swap static/v3/brand/hero.png for
// the designer's high-res original (same path) when available. // the designer's high-res original (same path) when available.
+4
View File
@@ -875,6 +875,10 @@
<script src="/static/v3/songs.js"></script> <script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script> <script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script> <script src="/static/v3/dashboard.js"></script>
<!-- First-run home tour: spotlights the home cards via the shared tour
engine (tour-engine.js, loaded above). Auto-runs once after onboarding
(triggered from profile.js finish()); replayable from the "?" menu. -->
<script src="/static/v3/onboarding-tour.js"></script>
<script src="/static/v3/feedbarcade.js"></script> <script src="/static/v3/feedbarcade.js"></script>
<script src="/static/v3/player-chrome.js"></script> <script src="/static/v3/player-chrome.js"></script>
<script> <script>
+115
View File
@@ -0,0 +1,115 @@
/*
* fee[dB]ack v0.3.0 first-run home tour.
*
* Registers a spotlight tour over the home-page cards with the shared tour
* engine (window.slopsmithTour / Shepherd) and auto-runs it once, the first
* time the user lands on the home page after completing onboarding. It stays
* replayable forever from the per-screen "?" tour menu (registered with
* screens: ['v3-home']).
*
* Anchors (all stable, on-screen while #v3-home is active):
* #v3-hero hero / Start Playing (dashboard.js)
* [data-tour="continue"] continue / pick a song (dashboard.js, 3 variants)
* #v3-instrument-wrap instrument selector badge (badges.js, topbar)
* #v3-tuner-wrap tuner badge (badges.js, topbar)
* #v3-audio-routing audio routing card (dashboard.js)
* [data-v3-open-profile] profile badge (profile.js, topbar)
* #v3-nav left sidebar navigation (index.html)
*/
(function () {
'use strict';
var TOUR_ID = 'home-onboarding';
// Each step dims the page and spotlights one element (shape: 'spotlight').
// waitFor blocks the step until its target exists, so the async dashboard
// re-render kicked off by 'v3:profile-updated' can't race the first step.
function buildSteps() {
return [
{
id: 'hero', shape: 'spotlight', position: 'bottom',
selector: '#v3-hero', waitFor: '#v3-hero',
title: 'Welcome to fee[dB]ack',
content: 'This is your home base. Hit Start Playing to drop straight into a song from your library.',
},
{
id: 'continue', shape: 'spotlight', position: 'left',
selector: '[data-tour="continue"]', waitFor: '[data-tour="continue"]',
title: 'Pick up where you left off',
content: 'Your last song resumes right here in one click. Before youve played anything, its a quick random pick to get you going.',
},
{
id: 'instrument', shape: 'spotlight', position: 'bottom',
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
title: 'Choose your instrument',
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
},
{
id: 'tuner', shape: 'spotlight', position: 'bottom',
selector: '#v3-tuner-wrap', waitFor: '#v3-tuner-wrap',
title: 'Tune up first',
content: 'Open the tuner and match each string until the meter centers — accurate tuning means accurate scoring.',
},
{
id: 'audio', shape: 'spotlight', position: 'top',
selector: '#v3-audio-routing', waitFor: '#v3-audio-routing',
title: 'Your signal path',
content: 'Input → amp / NAM / IR → output, at a glance. Set up and monitor your gear from this card.',
},
{
id: 'profile', shape: 'spotlight', position: 'bottom',
selector: '[data-v3-open-profile]', waitFor: '[data-v3-open-profile]',
title: 'Track your progress',
content: 'Your profile, avatar and rank live here. Watch your accuracy climb and level up as you play.',
},
{
id: 'nav', shape: 'spotlight', position: 'right',
selector: '#v3-nav', waitFor: '#v3-nav',
title: 'Find everything here',
content: 'Browse your full library, lessons and plugins anytime. You can replay this tour from the ? button in the corner.',
},
];
}
function register() {
var t = window.slopsmithTour;
if (!t || typeof t.register !== 'function') return false;
t.register(TOUR_ID, {
name: 'Welcome tour', // label in the "?" tour menu
screens: ['v3-home'], // relevant only on the home screen
autoPrompt: false, // we auto-run it from onboarding; no toast nag
buildSteps: buildSteps,
});
return true;
}
// Auto-run once after a genuine onboarding completion. profile.js gates the
// call on !editing (a profile edit must not relaunch it); we additionally
// honour the engine's own seen/dismissed state so it never repeats and a
// dismissal isn't nagged. Stays replayable from the "?" menu either way.
function startFirstRun() {
var t = window.slopsmithTour;
if (!t || typeof t.start !== 'function') return;
try {
if (t.hasSeen(TOUR_ID) || t.hasDismissed(TOUR_ID)) return;
} catch (e) { /* private mode — fall through and attempt once */ }
// Make sure the home screen is in view so the spotlight targets exist;
// the per-step waitFor handles the async dashboard render.
if (typeof window.showScreen === 'function') {
try { window.showScreen('v3-home'); } catch (e) { /* best-effort */ }
}
// Defer a frame so the 'v3:profile-updated' dashboard re-render has a
// chance to begin before Shepherd starts polling for the first target.
var raf = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };
raf(function () { try { t.start(TOUR_ID); } catch (e) { /* degrade */ } });
}
window.v3OnboardingTour = { startFirstRun: startFirstRun };
// tour-engine.js assigns window.slopsmithTour at script-eval time, so if it
// is loaded before us register() succeeds immediately; otherwise retry once
// the DOM (and the engine) are ready.
if (!register()) {
document.addEventListener('DOMContentLoaded', register, { once: true });
}
})();
+13 -2
View File
@@ -462,7 +462,8 @@
return body; return body;
} }
async function finish() { async function finish(finishOpts) {
finishOpts = finishOpts || {};
overlay.remove(); overlay.remove();
await fetchProgress(); await fetchProgress();
if (window.v3Progression && typeof window.v3Progression.refresh === 'function') { if (window.v3Progression && typeof window.v3Progression.refresh === 'function') {
@@ -471,6 +472,16 @@
renderBadge(); renderBadge();
renderProfileScreen(); renderProfileScreen();
if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('v3:profile-updated', _profile); 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 () => { submit.addEventListener('click', async () => {
@@ -529,7 +540,7 @@
// Step 4 — "Play it now": leave calibration pending (it completes // Step 4 — "Play it now": leave calibration pending (it completes
// through the normal scored-stats path) and launch the diagnostic. // through the normal scored-stats path) and launch the diagnostic.
const target = diagnosticFilename; const target = diagnosticFilename;
await finish(); await finish({ launchingSong: !!target });
if (target && typeof window.playSong === 'function') window.playSong(target); if (target && typeof window.playSong === 'function') window.playSong(target);
}); });