From c7fb074111751b375a263c873ba621104d6bf756 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Fri, 19 Jun 2026 16:32:33 +0200 Subject: [PATCH] feat(onboarding): first-run home tour (spotlight coach marks) (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- static/capabilities.js | 21 +++-- static/capabilities/midi-input.js | 6 +- static/tour-engine.css | 147 +++++++++++++++++++++++------- static/tour-engine.js | 25 ++++- static/v3/badges.js | 2 +- static/v3/dashboard.js | 8 +- static/v3/index.html | 4 + static/v3/onboarding-tour.js | 115 +++++++++++++++++++++++ static/v3/profile.js | 15 ++- 9 files changed, 293 insertions(+), 50 deletions(-) create mode 100644 static/v3/onboarding-tour.js diff --git a/static/capabilities.js b/static/capabilities.js index 14720f4..ceee7b9 100644 --- a/static/capabilities.js +++ b/static/capabilities.js @@ -816,15 +816,18 @@ } function _withTimeout(promise, timeoutMs, participant) { - return Promise.race([ - promise, - new Promise(resolve => { - setTimeout(() => resolve({ - outcome: 'failed', - reason: `Handler ${participant.pluginId} timed out after ${timeoutMs} ms`, - }), timeoutMs); - }), - ]); + // Capture + clear the timer once the race settles — otherwise a handler + // that resolves first leaves a live setTimeout (up to timeoutMs) that + // keeps the event loop alive and, for long overrides (MIDI permission + // commands at 15s), accumulates delayed callbacks across repeated calls. + let timer; + const timeout = new Promise(resolve => { + 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) { diff --git a/static/capabilities/midi-input.js b/static/capabilities/midi-input.js index 5193144..10161b1 100644 --- a/static/capabilities/midi-input.js +++ b/static/capabilities/midi-input.js @@ -371,7 +371,11 @@ _registerProvider({ providerId: '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 () => { access = await navigator.requestMIDIAccess({ sysex: false }); try { access.onstatechange = () => { _discover(); }; } catch (_) { /* best-effort */ } diff --git a/static/tour-engine.css b/static/tour-engine.css index 06bfc50..0fad12c 100644 --- a/static/tour-engine.css +++ b/static/tour-engine.css @@ -1,7 +1,9 @@ /* ── Consolidated tour menu — one floating button + popover for all tours ── */ -/* Palette aligned with the app's dark theme: #4080e0 accent, #e8c040 gold, - #181830 dark background, #cbd5e1 / #94a3b8 / #64748b text scale. Per - CLAUDE.md → Frontend Conventions. */ +/* Palette aligned with the v3 fee[dB]ack design tokens (tailwind.config.js): + fb-card #1e293b surfaces, fb-cardMuted #0b1220 wells, fb-primary #0ea5e9 + 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 { position: fixed; @@ -14,20 +16,20 @@ width: 32px; height: 32px; border-radius: 50%; - background: #181830; - border: 1.5px solid #4080e0; - color: #cbd5e1; + background: #1e293b; + border: 1.5px solid #0ea5e9; + color: #f8fafc; font-size: 15px; font-weight: bold; cursor: pointer; - box-shadow: 0 0 6px #4080e066; + box-shadow: 0 0 6px #0ea5e966; display: flex; align-items: center; justify-content: center; transition: box-shadow 0.2s, transform 0.15s; } .slopsmith-tour-menu-btn:hover { - box-shadow: 0 0 12px #4080e0aa; + box-shadow: 0 0 12px #0ea5e9aa; transform: translateY(-1px); } .slopsmith-tour-menu-btn.has-unseen { @@ -41,12 +43,12 @@ width: 10px; height: 10px; background: #e8c040; - border: 2px solid #181830; + border: 2px solid #1e293b; border-radius: 50%; } @keyframes tour-pulse { - 0%, 100% { box-shadow: 0 0 6px #4080e066; } - 50% { box-shadow: 0 0 16px #4080e0cc; } + 0%, 100% { box-shadow: 0 0 6px #0ea5e966; } + 50% { box-shadow: 0 0 16px #0ea5e9cc; } } .slopsmith-tour-menu-popover { @@ -62,12 +64,12 @@ room for the trigger button + its bottom inset. */ max-height: calc(100vh - 80px); overflow-y: auto; - background: #181830; - border: 1px solid #4080e044; + background: #1e293b; + border: 1px solid #0ea5e944; border-radius: 8px; padding: 6px; font-size: 13px; - color: #cbd5e1; + color: #f8fafc; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); } .slopsmith-tour-menu-popover .tour-menu-header { @@ -75,13 +77,13 @@ font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; - color: #64748b; - border-bottom: 1px solid #1e1e3a; + color: #94a3b8; + border-bottom: 1px solid #334155; margin-bottom: 4px; } .slopsmith-tour-menu-popover .tour-menu-empty { padding: 10px; - color: #64748b; + color: #94a3b8; font-style: italic; text-align: center; } @@ -95,23 +97,23 @@ background: transparent; border: none; border-radius: 4px; - color: #cbd5e1; + color: #f8fafc; font-size: 13px; text-align: left; cursor: pointer; transition: background 0.12s; } .slopsmith-tour-menu-popover .tour-menu-item:hover { - background: #1a1a30; - color: #fff; + background: #334155; + color: #f8fafc; } /* Keyboard focus gets an explicit ring instead of relying on the hover background — matches the :focus-visible treatment elsewhere in the app (style.css). Pointer focus is left alone. */ .slopsmith-tour-menu-popover .tour-menu-item:focus-visible { - background: #1a1a30; - color: #fff; - outline: 2px solid #4080e0; + background: #334155; + color: #f8fafc; + outline: 2px solid #0ea5e9; outline-offset: -2px; } .slopsmith-tour-menu-popover .tour-menu-item-label { @@ -127,11 +129,11 @@ } .slopsmith-tour-menu-popover .tour-menu-item-status.is-new { background: #e8c040; - color: #181830; + color: #0f172a; } .slopsmith-tour-menu-popover .tour-menu-item-status.is-seen { - background: #1e1e3a; - color: #64748b; + background: #0b1220; + color: #94a3b8; } /* ── First-visit toast — anchored above the menu button ── */ @@ -141,12 +143,12 @@ bottom: 56px; right: 12px; z-index: 202; - background: #181830; - border: 1px solid #4080e044; + background: #1e293b; + border: 1px solid #0ea5e944; border-radius: 8px; padding: 10px 14px; font-size: 13px; - color: #cbd5e1; + color: #f8fafc; max-width: 240px; line-height: 1.4; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); @@ -159,7 +161,7 @@ .slopsmith-tour-prompt .tour-prompt-more { margin-top: 4px; font-size: 11px; - color: #64748b; + color: #94a3b8; } .slopsmith-tour-prompt .tour-prompt-buttons { display: flex; @@ -178,11 +180,92 @@ opacity: 0.85; } .slopsmith-tour-prompt button[data-action="start"] { - background: #4080e0; + background: #0ea5e9; color: #fff; font-weight: 600; } .slopsmith-tour-prompt button[data-action="dismiss"] { - background: #1e1e3a; + background: #334155; 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; +} diff --git a/static/tour-engine.js b/static/tour-engine.js index 17089b3..f15ff87 100644 --- a/static/tour-engine.js +++ b/static/tour-engine.js @@ -127,7 +127,13 @@ } 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 ──────────────────────────────────────────────────────────── @@ -615,7 +621,24 @@ onStart: opts.onStart || null, onComplete: opts.onComplete || 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. _updateMenuVisibility(); } diff --git a/static/v3/badges.js b/static/v3/badges.js index 3398e91..367c7c2 100644 --- a/static/v3/badges.js +++ b/static/v3/badges.js @@ -291,7 +291,7 @@ const host = document.getElementById('v3-badge-instrument'); if (!host) return; host.innerHTML = - '
' + + '
' + ''; } else if (pick) { continueCard = - ''; } else { continueCard = - '
' + + '
' + '
Pick a song to get started
' + '
'; } @@ -188,7 +188,7 @@ 'Patch Notes for ' + esc(ver) + '?

' : '') + // Featured grid: hero + continue '
' + - '
' + + '
' + // Hero artwork (neon note-highway), right-anchored. Placeholder // cropped from the design mock — swap static/v3/brand/hero.png for // the designer's high-res original (same path) when available. diff --git a/static/v3/index.html b/static/v3/index.html index e1dd861..535d11e 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -875,6 +875,10 @@ + +