feat(gui): Toast notification system for mapping fires (#487)#522
feat(gui): Toast notification system for mapping fires (#487)#522amiable-dev wants to merge 6 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements a toast notification system (ADR-014 Phase 4) for mapping fire feedback in the GUI. When a MIDI/HID mapping fires, a transient overlay notification is shown in the workspace panel's bottom-right corner with the trigger→action summary, auto-dismisses after 3 seconds, and supports hover-pause, error persistence, and coalescence for continuous controls. The feature is gated behind toastsEnabled/toastsContinuous store toggles.
Changes:
- New
toasts.jsstore implementing the full toast queue with auto-dismiss timers, pause/resume, coalescence, and max-visible enforcement. - New
ToastOverlay.sveltecomponent with slide-in animation andprefers-reduced-motionsupport, mounted inWorkspacePanel. - Integration in
pushFiredEvent()inevents.js, plus 30 new tests and mock updates across 8 existing test files.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
conductor-gui/ui/src/lib/stores/toasts.js |
New toast queue store with full lifecycle management |
conductor-gui/ui/src/lib/stores/toasts.test.ts |
15 new unit tests for all store behaviors |
conductor-gui/ui/src/lib/stores/events.js |
Adds toastsEnabled/toastsContinuous stores and toast emission in pushFiredEvent() |
conductor-gui/ui/src/lib/stores/events.test.ts |
5 new integration tests; adds clearToasts() to beforeEach |
conductor-gui/ui/src/lib/components/ToastOverlay.svelte |
New overlay component with CSS animations |
conductor-gui/ui/src/lib/components/ToastOverlay.test.ts |
10 new component rendering/interaction tests |
conductor-gui/ui/src/lib/panels/WorkspacePanel.svelte |
Mounts ToastOverlay, adds position: relative |
conductor-gui/ui/src/lib/panels/WorkspacePanel.test.ts |
Adds toasts.js mock for ToastOverlay |
conductor-gui/ui/src/lib/panels/EventStreamPanel.test.ts |
Adds new toast store exports to mock |
conductor-gui/ui/src/lib/components/EventRow.test.ts |
Adds new toast store exports to mock |
conductor-gui/ui/src/lib/components/EventFilter.test.ts |
Adds new toast store exports to mock |
conductor-gui/ui/src/lib/components/DeviceStatusPills.test.ts |
Adds new toast store exports to mock |
conductor-gui/ui/src/App.test.ts |
Adds new toast store exports to mock |
CHANGELOG.md |
Documents new toast notification system |
You can also share your feedback on Copilot code review. Take the survey.
|
|
||
| import { describe, it, expect, vi, afterEach } from 'vitest'; | ||
| import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; | ||
| import { get } from 'svelte/store'; |
There was a problem hiding this comment.
The get import from 'svelte/store' on line 7 is never used in the test file. It was likely included by accident and can be safely removed to keep the imports clean.
| import { get } from 'svelte/store'; |
| </script> | ||
|
|
||
| {#if $toastQueue.length > 0} | ||
| <div class="toast-container"> |
There was a problem hiding this comment.
The toast container lacks an aria-live region and no role attribute. Screen readers won't automatically announce new toast notifications to users who rely on assistive technologies. The container should have aria-live="polite" (or aria-live="assertive" for errors) and role="status" (or role="alert" for errors) so that new toasts are announced as they appear. Since the queue may contain mixed success and error toasts, consider wrapping them separately or using aria-live="assertive" on the container so errors are announced immediately.
| <div class="toast-container"> | |
| <div class="toast-container" aria-live="polite" role="status"> |
| const TRIGGER_ICONS = { | ||
| note: '🎵', short_press: '🎵', medium_press: '🎵', long_press: '⏱', | ||
| double_tap: '👆', chord: '🎶', cc: '🎛', encoder: '🔄', | ||
| aftertouch: '👇', pitch_bend: '↕', gamepad_button: '🎮', | ||
| gamepad_chord: '🎮', gamepad_analog: '🎮', gamepad_trigger: '🎮', | ||
| }; |
There was a problem hiding this comment.
The TRIGGER_ICONS lookup map is defined inside the if (get(toastsEnabled)) block on every call to pushFiredEvent(). Since this function is called for every mapping fire event (potentially high-frequency for continuous controls), the object literal is re-allocated on every call. It should be moved to module scope as a constant to avoid unnecessary allocations, similar to how CONTINUOUS_TYPES is defined as a module-level Set.
9f183af to
dfe59e0
Compare
Add transient workspace toast overlays for mapping fire feedback (ADR-014 Phase 4). New toasts.js store with auto-dismiss, hover-pause, error persistence, coalescence for continuous controls, and max 3 visible. ToastOverlay component mounted in WorkspacePanel with slide-in animation. Integration in pushFiredEvent() gated by toastsEnabled/toastsContinuous. 30 new tests (15 store + 10 component + 5 integration). Closes #487 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dfe59e0 to
945f641
Compare
…ckup Toast notifications now use 5px colored dots with --event-* CSS variables (note/cc/encoder/aftertouch/pitchbend/gamepad) instead of emoji characters, aligning with the mapping-feedback-mockups.html design language. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Match mockup CSS: padding 6px 14px, box-shadow 16px spread, font-size --font-size-base, white-space nowrap on .toast, translateY animation entry direction, use --radius-md variable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
You can also share your feedback on Copilot code review. Take the survey.
| type="button" | ||
| class="toast-dismiss" | ||
| on:click={() => dismissToast(toast.id)} | ||
| aria-label="Dismiss" |
There was a problem hiding this comment.
When multiple toasts are visible simultaneously (up to MAX_VISIBLE=3), all dismiss buttons share the same aria-label="Dismiss", making it impossible for screen reader users to distinguish which toast each button dismisses. The aria-label should include the toast title to provide context, e.g., aria-label="Dismiss: {toast.title}". This pattern is consistent with how the codebase includes descriptive aria-labels (e.g., ProfileManager.svelte uses context-specific labels).
| aria-label="Dismiss" | |
| aria-label={`Dismiss: ${toast.title}`} |
| const updatedType = type; | ||
| const updatedTtl = ttl; | ||
| const updated = [...q]; | ||
| updated[idx] = { | ||
| ...existing, | ||
| subtitle: toast.subtitle, | ||
| title: toast.title, | ||
| dotClass: toast.dotClass ?? existing.dotClass, | ||
| type: updatedType, | ||
| ttl: updatedTtl, | ||
| timestamp: now, | ||
| }; | ||
| coalesced = true; | ||
| // Reset dismiss timer (skip for error toasts) | ||
| if (updatedType !== 'error' && !_paused.has(existing.id)) { | ||
| _startTimer(existing.id, updatedTtl); | ||
| } else if (updatedType === 'error') { |
There was a problem hiding this comment.
The local variables updatedType and updatedTtl on lines 73–74 are just aliases for the outer type and ttl variables already accessible in the closure. They add no clarity and can be removed by using type and ttl directly in the object spread and the subsequent conditional checks.
| const updatedType = type; | |
| const updatedTtl = ttl; | |
| const updated = [...q]; | |
| updated[idx] = { | |
| ...existing, | |
| subtitle: toast.subtitle, | |
| title: toast.title, | |
| dotClass: toast.dotClass ?? existing.dotClass, | |
| type: updatedType, | |
| ttl: updatedTtl, | |
| timestamp: now, | |
| }; | |
| coalesced = true; | |
| // Reset dismiss timer (skip for error toasts) | |
| if (updatedType !== 'error' && !_paused.has(existing.id)) { | |
| _startTimer(existing.id, updatedTtl); | |
| } else if (updatedType === 'error') { | |
| const updated = [...q]; | |
| updated[idx] = { | |
| ...existing, | |
| subtitle: toast.subtitle, | |
| title: toast.title, | |
| dotClass: toast.dotClass ?? existing.dotClass, | |
| type, | |
| ttl, | |
| timestamp: now, | |
| }; | |
| coalesced = true; | |
| // Reset dismiss timer (skip for error toasts) | |
| if (type !== 'error' && !_paused.has(existing.id)) { | |
| _startTimer(existing.id, ttl); | |
| } else if (type === 'error') { |
…ckup Four fixes to match the mockup exactly: 1. **Center positioning**: toast-container uses left:50%/translateX(-50%) with column-reverse stacking (newest at bottom), matching mockup 2. **Content structure**: Replace title/subtitle two-line layout with flat inline spans: dot · trigger → action · result-pill, matching mockup's toast-trigger/toast-arrow/toast-action/toast-result pattern 3. **Overflow clipping**: Remove overflow:hidden from .workspace-panel so toast container isn't clipped by the mapping section bounds 4. **Result pill**: Add .toast-result.ok/.err pill styling matching mockup's green-08 background badge (e.g., "opened", "OK", "fail") Data model changed: trigger/action/result fields replace title/subtitle. Dismiss button hidden by default, shown on hover (less visual noise). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… aliases - Dismiss button aria-label now includes toast trigger for screen reader context: "Dismiss: DoubleTap 87" instead of generic "Dismiss" - Remove updatedType/updatedTtl aliases in coalescence — use outer type/ttl closure variables directly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace emoji icon with CSS-colored dot, red "Fired" label with green, plain-text detail with structured trigger → action + result pill layout, and add green-tint background matching mapping-feedback-mockups.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 7 comments.
You can also share your feedback on Copilot code review. Take the survey.
| pushToast({ | ||
| trigger: triggerLabel, | ||
| action: actionSummary || actionType, | ||
| result: isError ? (firedPayload.error || 'fail') : (firedPayload.result_short || 'OK'), |
There was a problem hiding this comment.
firedPayload.result_short is referenced when constructing the toast result label, but result_short is not a field on MappingFiredPayload (the Rust payload defines only result: FiredResult serialized as "ok" / "error"). This means result_short will always be undefined, so the fallback 'OK' is always used for success toasts, and the actual result value (e.g. "ok", or any richer outcome string) is never shown. The correct field to use here is firedPayload.result (i.e. firedPayload.result || 'OK').
| result: isError ? (firedPayload.error || 'fail') : (firedPayload.result_short || 'OK'), | |
| result: isError ? (firedPayload.error || 'fail') : (firedPayload.result || 'OK'), |
| export function getFiredTriggerLabel(event: unknown): string { | ||
| if (!isFiredEvent(event)) return '?'; | ||
| const t = event.trigger; | ||
| if (!t) return '?'; | ||
| const type = t.type ?? '?'; | ||
| const num = t.number; | ||
| // Capitalize trigger type for display | ||
| const label = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); | ||
| return num != null ? `${label} ${num}` : label; | ||
| } | ||
|
|
||
| /** | ||
| * Build a human-readable action label for a fired event. | ||
| * Uses action.summary if available, falls back to action.type. | ||
| */ | ||
| export function getFiredActionLabel(event: unknown): string { | ||
| if (!isFiredEvent(event)) return '?'; | ||
| return event.action?.summary ?? event.action?.type ?? 'Action'; | ||
| } | ||
|
|
||
| /** | ||
| * Build a short result string for the fired event result pill. | ||
| * E.g. "✓ OK", "✗ fail", "✓ opened" | ||
| */ | ||
| export function getFiredResultLabel(event: unknown): { text: string; isError: boolean } { | ||
| if (!isFiredEvent(event)) return { text: '?', isError: false }; | ||
| const isError = event.result === 'error' || !!event.error; | ||
| if (isError) { | ||
| return { text: `✗ ${event.error || 'fail'}`, isError: true }; | ||
| } | ||
| return { text: `✓ ${event.result || 'OK'}`, isError: false }; | ||
| } |
There was a problem hiding this comment.
The event-helpers.test.ts file has comprehensive test coverage for all other functions in event-helpers.ts, but the three new functions added in this PR — getFiredTriggerLabel, getFiredActionLabel, and getFiredResultLabel — have no direct unit tests in that file. These functions contain non-trivial logic (capitalization, underscore-to-space conversion, error detection, action fallback chain), and adding tests in event-helpers.test.ts would be consistent with the established testing pattern for this file.
| {#if $toastQueue.length > 0} | ||
| <div class="toast-container" aria-live="polite" role="status"> | ||
| {#each $toastQueue as toast (toast.id)} | ||
| <!-- svelte-ignore a11y-no-static-element-interactions --> | ||
| <div | ||
| class="toast {toast.type}" | ||
| on:mouseenter={() => pauseToast(toast.id)} | ||
| on:mouseleave={() => resumeToast(toast.id)} | ||
| > | ||
| <span class="toast-dot" style="background:var(--event-{toast.dotClass || 'note'})"></span> | ||
| <span class="toast-trigger">{toast.trigger}</span> | ||
| <span class="toast-arrow">→</span> | ||
| <span class="toast-action">{toast.action}</span> | ||
| {#if toast.type === 'error'} | ||
| <span class="toast-result err">{toast.result}</span> | ||
| {:else} | ||
| <span class="toast-result ok">{toast.result}</span> | ||
| {/if} | ||
| <button | ||
| type="button" | ||
| class="toast-dismiss" | ||
| on:click={() => dismissToast(toast.id)} | ||
| aria-label={`Dismiss: ${toast.trigger}`} | ||
| >×</button> | ||
| </div> | ||
| {/each} | ||
| </div> | ||
| {/if} |
There was a problem hiding this comment.
The aria-live="polite" region is only mounted when $toastQueue.length > 0 (via the {#if} block). Screen readers typically need the aria-live region to be present in the DOM before new content is injected into it — if it's inserted at the same time as the content, some screen readers may miss the announcement entirely. The toast-container div should always be rendered (even when the queue is empty), with the {#each} conditional remaining inside it, so the live region is established before any toasts are added.
| {#if $toastQueue.length > 0} | |
| <div class="toast-container" aria-live="polite" role="status"> | |
| {#each $toastQueue as toast (toast.id)} | |
| <!-- svelte-ignore a11y-no-static-element-interactions --> | |
| <div | |
| class="toast {toast.type}" | |
| on:mouseenter={() => pauseToast(toast.id)} | |
| on:mouseleave={() => resumeToast(toast.id)} | |
| > | |
| <span class="toast-dot" style="background:var(--event-{toast.dotClass || 'note'})"></span> | |
| <span class="toast-trigger">{toast.trigger}</span> | |
| <span class="toast-arrow">→</span> | |
| <span class="toast-action">{toast.action}</span> | |
| {#if toast.type === 'error'} | |
| <span class="toast-result err">{toast.result}</span> | |
| {:else} | |
| <span class="toast-result ok">{toast.result}</span> | |
| {/if} | |
| <button | |
| type="button" | |
| class="toast-dismiss" | |
| on:click={() => dismissToast(toast.id)} | |
| aria-label={`Dismiss: ${toast.trigger}`} | |
| >×</button> | |
| </div> | |
| {/each} | |
| </div> | |
| {/if} | |
| <div class="toast-container" aria-live="polite" role="status"> | |
| {#each $toastQueue as toast (toast.id)} | |
| <!-- svelte-ignore a11y-no-static-element-interactions --> | |
| <div | |
| class="toast {toast.type}" | |
| on:mouseenter={() => pauseToast(toast.id)} | |
| on:mouseleave={() => resumeToast(toast.id)} | |
| > | |
| <span class="toast-dot" style="background:var(--event-{toast.dotClass || 'note'})"></span> | |
| <span class="toast-trigger">{toast.trigger}</span> | |
| <span class="toast-arrow">→</span> | |
| <span class="toast-action">{toast.action}</span> | |
| {#if toast.type === 'error'} | |
| <span class="toast-result err">{toast.result}</span> | |
| {:else} | |
| <span class="toast-result ok">{toast.result}</span> | |
| {/if} | |
| <button | |
| type="button" | |
| class="toast-dismiss" | |
| on:click={() => dismissToast(toast.id)} | |
| aria-label={`Dismiss: ${toast.trigger}`} | |
| >×</button> | |
| </div> | |
| {/each} | |
| </div> |
| <div class="fired-detail-panel" class:failed-border={firedIsError}> | ||
| <div class="detail-line"><span class="detail-label">Trigger</span> <span class="detail-value">{event.trigger?.type ?? '?'} #{event.trigger?.number ?? '?'} v={event.trigger?.value ?? '?'}{event.trigger?.device ? ` (${event.trigger.device})` : ''}</span></div> | ||
| <div class="detail-line"><span class="detail-label">Action</span> <span class="detail-value">{event.action?.type ?? '?'}: {event.action?.summary ?? '?'}</span></div> | ||
| <div class="detail-line"><span class="detail-label">Result</span> <span class="detail-value" class:detail-ok={!firedIsError} class:detail-err={firedIsError}>{firedResult?.text ?? '?'}{event.error ? ` — ${event.error}` : ''}</span></div> |
There was a problem hiding this comment.
In the fired detail panel's "Result" row, the error message is shown twice. firedResult?.text already contains the error string (e.g. "✗ App not found" from getFiredResultLabel), and then event.error ? — ${event.error} : '' appends the same error again, producing output like "✗ App not found — App not found". The trailing event.error conditional should be removed from this line, since the error information is already captured in firedResult.text.
| <div class="detail-line"><span class="detail-label">Result</span> <span class="detail-value" class:detail-ok={!firedIsError} class:detail-err={firedIsError}>{firedResult?.text ?? '?'}{event.error ? ` — ${event.error}` : ''}</span></div> | |
| <div class="detail-line"><span class="detail-label">Result</span> <span class="detail-value" class:detail-ok={!firedIsError} class:detail-err={firedIsError}>{firedResult?.text ?? '?'}</span></div> |
| /** Trigger type → CSS dot class mapping for toast notifications (matches --event-* theme vars) */ | ||
| const TRIGGER_DOT_CLASS = { | ||
| note: 'note', short_press: 'note', medium_press: 'note', long_press: 'note', | ||
| double_tap: 'note', chord: 'note', cc: 'cc', encoder: 'encoder', | ||
| aftertouch: 'aftertouch', pitch_bend: 'pitchbend', gamepad_button: 'gamepad', | ||
| gamepad_chord: 'gamepad', gamepad_analog: 'gamepad', gamepad_trigger: 'gamepad', | ||
| }; | ||
|
|
There was a problem hiding this comment.
The TRIGGER_DOT_CLASS mapping in events.js assigns 'encoder' to the 'encoder' trigger type unconditionally. However, getFiredDotClass in event-helpers.ts correctly handles the case where encoder trigger numbers >= 128 are actually gamepad analog/trigger events — returning 'gamepad' in that case. The toast built in pushFiredEvent will always use 'encoder' dot class for gamepad analog/trigger fires (where trigger.number >= 128), producing an incorrect purple dot instead of the correct gamepad color. The TRIGGER_DOT_CLASS lookup should be replaced with a call to (or mirrored logic from) getFiredDotClass to stay consistent with the EventRow rendering.
| /** Trigger type → CSS dot class mapping for toast notifications (matches --event-* theme vars) */ | |
| const TRIGGER_DOT_CLASS = { | |
| note: 'note', short_press: 'note', medium_press: 'note', long_press: 'note', | |
| double_tap: 'note', chord: 'note', cc: 'cc', encoder: 'encoder', | |
| aftertouch: 'aftertouch', pitch_bend: 'pitchbend', gamepad_button: 'gamepad', | |
| gamepad_chord: 'gamepad', gamepad_analog: 'gamepad', gamepad_trigger: 'gamepad', | |
| }; | |
| /** Trigger type → CSS dot class baseline mapping for toast notifications (matches --event-* theme vars) */ | |
| const TRIGGER_DOT_CLASS = { | |
| note: 'note', short_press: 'note', medium_press: 'note', long_press: 'note', | |
| double_tap: 'note', chord: 'note', cc: 'cc', | |
| aftertouch: 'aftertouch', pitch_bend: 'pitchbend', gamepad_button: 'gamepad', | |
| gamepad_chord: 'gamepad', gamepad_analog: 'gamepad', gamepad_trigger: 'gamepad', | |
| }; | |
| /** | |
| * Compute the CSS dot class for a fired trigger used in toast notifications. | |
| * | |
| * Mirrors the encoder/gamepad handling from getFiredDotClass: encoders with number >= 128 | |
| * are actually gamepad analog/trigger events and should use the gamepad color. | |
| */ | |
| export function getTriggerDotClassForToast(trigger) { | |
| if (!trigger || typeof trigger !== 'object') return 'note'; | |
| const { type, number } = trigger; | |
| if (type === 'encoder' && typeof number === 'number' && number >= 128) { | |
| return 'gamepad'; | |
| } | |
| return TRIGGER_DOT_CLASS[type] || 'note'; | |
| } |
| opacity: 0; | ||
| transition: opacity 0.15s; | ||
| } | ||
|
|
||
| .toast:hover .toast-dismiss { | ||
| opacity: 0.5; | ||
| } |
There was a problem hiding this comment.
The .toast-dismiss button starts with opacity: 0 and only becomes visible on hover (.toast:hover .toast-dismiss). There is no focus-visible rule to show the button when it receives keyboard focus. This means keyboard-only and assistive technology users cannot see the dismiss button when navigating to it — the button exists in the DOM and is reachable via Tab but invisible, which is an accessibility violation. A :focus-visible selector (e.g. .toast-dismiss:focus-visible { opacity: 1; }) should be added to make the button visible when focused.
| .toast-container { | ||
| position: absolute; | ||
| bottom: 12px; | ||
| left: 50%; | ||
| transform: translateX(-50%); |
There was a problem hiding this comment.
The PR description and the stored memory both say the ToastOverlay is positioned "bottom-right," but the implementation uses left: 50%; transform: translateX(-50%), which centers the container horizontally. The component comment also says "bottom-center." If bottom-right was the intended final position per the ADR and PR description, the CSS should use right: 14px instead. If bottom-center is the intended position, the PR description should be updated.
Summary
toasts.jsstore with auto-dismiss (3s), hover-pause, error persistence, coalescence for continuous controls, max 3 visibleToastOverlay.sveltecomponent mounted in WorkspacePanel (bottom-right, above status bar) with slide-in animation andprefers-reduced-motionsupportpushFiredEvent()gated bytoastsEnabled/toastsContinuoussettings — continuous controls (CC, encoder, pitch_bend, aftertouch) suppressed by default_pausedcleanup on eviction, coalescence timer reset,get()instead of subscribe hackCloses #487
Test plan
🤖 Generated with Claude Code