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 @@ + +