Skip to content

feat(gui): Toast notification system for mapping fires (#487)#522

Open
amiable-dev wants to merge 6 commits intomainfrom
feat/487-toast-notifications
Open

feat(gui): Toast notification system for mapping fires (#487)#522
amiable-dev wants to merge 6 commits intomainfrom
feat/487-toast-notifications

Conversation

@amiable-dev
Copy link
Owner

Summary

  • New toasts.js store with auto-dismiss (3s), hover-pause, error persistence, coalescence for continuous controls, max 3 visible
  • New ToastOverlay.svelte component mounted in WorkspacePanel (bottom-right, above status bar) with slide-in animation and prefers-reduced-motion support
  • Integration in pushFiredEvent() gated by toastsEnabled/toastsContinuous settings — continuous controls (CC, encoder, pitch_bend, aftertouch) suppressed by default
  • 30 new tests: 15 store, 10 component, 5 integration
  • Council review fixes: _paused cleanup on eviction, coalescence timer reset, get() instead of subscribe hack

Closes #487

Test plan

  • 15 toast store tests (queue, push, dismiss, clear, coalescence, pause/resume, auto-dismiss, error persistence, max visible)
  • 10 ToastOverlay component tests (rendering, dismiss, hover, classes)
  • 5 integration tests (toast pushing from pushFiredEvent, toastsEnabled/toastsContinuous gating, error toasts)
  • 58 events store tests pass (no regressions)
  • Full suite: 804 passed, 1 pre-existing failure (WorkspacePanel/PluginMarketplace)

🤖 Generated with Claude Code

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js store implementing the full toast queue with auto-dismiss timers, pause/resume, coalescence, and max-visible enforcement.
  • New ToastOverlay.svelte component with slide-in animation and prefers-reduced-motion support, mounted in WorkspacePanel.
  • Integration in pushFiredEvent() in events.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';
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
import { get } from 'svelte/store';

Copilot uses AI. Check for mistakes.
</script>

{#if $toastQueue.length > 0}
<div class="toast-container">
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<div class="toast-container">
<div class="toast-container" aria-live="polite" role="status">

Copilot uses AI. Check for mistakes.
Comment on lines +297 to +302
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: '🎮',
};
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@amiable-dev amiable-dev force-pushed the feat/487-toast-notifications branch 3 times, most recently from 9f183af to dfe59e0 Compare March 8, 2026 10:18
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>
@amiable-dev amiable-dev force-pushed the feat/487-toast-notifications branch from dfe59e0 to 945f641 Compare March 8, 2026 17:26
amiable-dev and others added 2 commits March 8, 2026 17:32
…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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
aria-label="Dismiss"
aria-label={`Dismiss: ${toast.title}`}

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +89
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') {
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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') {

Copilot uses AI. Check for mistakes.
amiable-dev and others added 3 commits March 8, 2026 18:22
…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>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'),
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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').

Suggested change
result: isError ? (firedPayload.error || 'fail') : (firedPayload.result_short || 'OK'),
result: isError ? (firedPayload.error || 'fail') : (firedPayload.result || 'OK'),

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +245
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 };
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +46
{#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">&rarr;</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}`}
>&times;</button>
</div>
{/each}
</div>
{/if}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
{#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">&rarr;</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}`}
>&times;</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">&rarr;</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}`}
>&times;</button>
</div>
{/each}
</div>

Copilot uses AI. Check for mistakes.
<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>
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +31
/** 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',
};

Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/** 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';
}

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +134
opacity: 0;
transition: opacity 0.15s;
}

.toast:hover .toast-dismiss {
opacity: 0.5;
}
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +53
.toast-container {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ADR-014] Phase 4: Toast notification system for mapping fires

2 participants