mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-13 20:21:40 +00:00
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:
co-authored by
Claude Opus 4.8
parent
fb06e288e1
commit
c7fb074111
+12
-9
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 +
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 you’ve played anything, it’s 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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user