fix(onboarding): midi-input multi-provider discovery + home-tour lifecycle (#568)

Addresses Codex review of #526/#528:
- midi-input discover(): one provider's enumerate() rejection no longer aborts
  the whole discovery — other providers (e.g. a native/desktop MIDI provider)
  are still queried; denial is only reported when NO provider enumerates.
- Home tour now waits for a 'v3:dashboard-rendered' event (dashboard.js emits
  it after the #v3-home innerHTML swap) before attaching Shepherd, instead of a
  single animation frame that could latch onto pre-render nodes the async
  dashboard render then replaces.
- "Play it now" onboarding now arms the tour (armPendingFirstRun) to run the
  first time the user returns to v3-home, instead of silently never showing it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-22 14:06:28 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 820a18648a
commit 5d0229fc82
4 changed files with 70 additions and 9 deletions
+24 -2
View File
@@ -177,14 +177,31 @@
async function _discover() {
if (providers.size === 0) return _unavailable('No MIDI provider registered', _snapshot());
let found = 0;
let enumerated = 0;
let lastError = null;
for (const provider of providers.values()) {
if (!provider.handlers.enumerate) continue;
let list;
try { list = await provider.handlers.enumerate(); }
catch (e) {
// requestMIDIAccess rejection = permission denied / unsupported.
return _denied(_str(e && e.message, 'MIDI access denied'), _snapshot());
// One provider failing (e.g. browser Web-MIDI permission denied,
// or unsupported) must NOT hide other providers' devices (e.g. a
// native/desktop MIDI provider). Record it and keep going; denial
// is only reported below when NO provider enumerated successfully.
lastError = e;
// Drop this provider's now-unverifiable sources so revoked /
// unplugged devices don't linger as selectable and fail on a
// later open (it just failed to enumerate, so we can't trust its
// previous list).
for (const [key, s] of Array.from(sources.entries())) {
if (s.providerId === provider.id) {
_closeSessionInternal(key, 'enumerate-failed');
sources.delete(key);
}
}
continue;
}
enumerated += 1;
const fresh = new Set();
for (const raw of (Array.isArray(list) ? list : [])) {
const sourceId = _str(raw.sourceId || raw.id, '');
@@ -216,6 +233,11 @@
}
// Restore a previously-selected source if it reappeared.
if (selectedKey && !sources.has(selectedKey)) { /* keep the preference; it may return later */ }
// Only surface a denial when NO provider could enumerate — otherwise a
// single denied/unsupported provider would mask the working ones.
if (enumerated === 0 && lastError) {
return _denied(_str(lastError && lastError.message, 'MIDI access denied'), _snapshot());
}
_emit('sources-changed', { count: found });
_contributeDiagnostics();
return _handled(_snapshot({ discovered: found }));
+4
View File
@@ -240,6 +240,10 @@
if (window.v3AudioRouting && typeof window.v3AudioRouting.render === 'function') {
try { window.v3AudioRouting.render(document.getElementById('v3-audio-routing')); } catch (e) { /* */ }
}
// Signal that #v3-home has been (re)built, so the first-run onboarding
// tour can wait for the real cards instead of attaching to nodes from a
// prior render that this innerHTML swap just replaced.
try { document.dispatchEvent(new CustomEvent('v3:dashboard-rendered')); } catch (e) { /* older runtimes */ }
}
function statCard(value, unit, unitColor) {
+36 -7
View File
@@ -93,18 +93,47 @@
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.
// Make sure the home screen is in view so the spotlight targets exist.
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 */ } });
// Wait for the dashboard to finish (re)rendering #v3-home before
// attaching Shepherd. Starting after a single animation frame can latch
// onto the pre-render #v3-hero / [data-tour] nodes that the async render
// then replaces, breaking the tour. Listen for the render-complete
// event; fall back on a timeout in case the render already finished
// (no event coming) or never fires.
var started = false;
var go = function () {
if (started) return;
started = true;
try { document.removeEventListener('v3:dashboard-rendered', go); } catch (e) { /* */ }
try { t.start(TOUR_ID); } catch (e) { /* degrade */ }
};
try { document.addEventListener('v3:dashboard-rendered', go, { once: true }); } catch (e) { /* */ }
setTimeout(go, 1200);
}
window.v3OnboardingTour = { startFirstRun: startFirstRun };
// Onboarding paths that navigate straight to the player ("Play it now")
// can't start the tour immediately — there is no home screen to spotlight
// yet. Arm it to run the first time the user lands back on v3-home.
function armPendingFirstRun() {
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 */ }
var sm = window.slopsmith;
if (!sm || typeof sm.on !== 'function') return;
var onScreen = function (e) {
if (!(e && e.detail && e.detail.id === 'v3-home')) return;
try { if (typeof sm.off === 'function') sm.off('screen:changed', onScreen); } catch (_) { /* */ }
startFirstRun();
};
sm.on('screen:changed', onScreen);
}
window.v3OnboardingTour = { startFirstRun: startFirstRun, armPendingFirstRun: armPendingFirstRun };
// tour-engine.js assigns window.slopsmithTour at script-eval time, so if it
// is loaded before us register() succeeds immediately; otherwise retry once
+6
View File
@@ -520,6 +520,12 @@
if (!editing && !finishOpts.launchingSong &&
window.v3OnboardingTour && typeof window.v3OnboardingTour.startFirstRun === 'function') {
try { window.v3OnboardingTour.startFirstRun(); } catch (e) { /* never block onboarding */ }
} else if (!editing && finishOpts.launchingSong &&
window.v3OnboardingTour && typeof window.v3OnboardingTour.armPendingFirstRun === 'function') {
// "Play it now" navigates straight to the player, so the home
// tour can't run now — arm it to fire the first time the user
// returns to home, instead of silently never showing.
try { window.v3OnboardingTour.armPendingFirstRun(); } catch (e) { /* never block onboarding */ }
}
}