feat(mesh): device onboarding modal (backlog #6)

Guided prompt that pops up when a mesh radio is detected but not yet
connected -- wraps the existing Connect action (mesh.configure with
device_path) rather than building a new setup engine. Dismissible per
device path (won't re-prompt for the same undismissed-but-ignored device on
every poll tick). Not the whole-app identity/seed onboarding system
(useOnboarding.ts) -- confirmed unrelated, this is mesh-specific only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-30 23:24:12 -04:00
parent 712df2278f
commit 46dae75a0f

View File

@ -38,6 +38,13 @@ const sendError = ref('')
const broadcasting = ref(false)
const configuring = ref(false)
const connectingDevice = ref<string | null>(null)
// Onboarding modal (#6): guides a first-time connect for a freshly-detected,
// not-yet-connected device a friendlier wrapper around the same Connect
// action the "Detected USB devices" list already offers, not a new setup
// engine. `onboardingDismissed` remembers paths the user closed without
// connecting, so it doesn't reappear every poll tick for the same device.
const showOnboardingModal = ref(false)
const onboardingDismissed = ref<Set<string>>(new Set())
const chatScrollEl = ref<HTMLElement | null>(null)
const mobileShowChat = ref(false)
// Device status panel starts collapsed on mobile (expandable via its header).
@ -1014,11 +1021,34 @@ async function handleConnectDevice(devicePath: string) {
connectingDevice.value = devicePath
try {
await mesh.configure({ enabled: true, device_path: devicePath } as Partial<import('@/stores/mesh').MeshStatus>)
showOnboardingModal.value = false
} finally {
connectingDevice.value = null
}
}
const undismissedDetectedDevices = computed(() =>
(mesh.status?.detected_devices ?? []).filter((d) => !onboardingDismissed.value.has(d))
)
function dismissOnboarding() {
for (const d of undismissedDetectedDevices.value) onboardingDismissed.value.add(d)
showOnboardingModal.value = false
}
// Pop the onboarding modal the moment a device is detected but not yet
// connected same trigger condition the inline "Detected USB devices" list
// already uses (mesh.status.detected_devices non-empty + not connected),
// just surfaced as a guided prompt instead of requiring the user to notice
// the collapsed Device card.
watch(
() => [mesh.status?.device_connected, undismissedDetectedDevices.value.length] as const,
([connected, count]) => {
if (!connected && count > 0) showOnboardingModal.value = true
},
{ immediate: true },
)
function signalBars(rssi: number | null): number {
if (rssi === null) return 0
if (rssi > -60) return 4
@ -2308,6 +2338,32 @@ function isImageMime(mime?: string): boolean {
</div>
</div>
<!-- Onboarding modal (#6): guided first-connect prompt for a freshly
detected, not-yet-connected mesh device wraps the same Connect
action the inline "Detected USB devices" list already offers. -->
<div v-if="showOnboardingModal" class="mesh-transport-modal-backdrop" @click.self="dismissOnboarding">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">📡 Mesh Device Found</h3>
<p class="mesh-transport-sub">
A radio was detected but isn't connected yet. Connect it to start using off-grid mesh chat.
</p>
<div class="mesh-transport-options">
<button
v-for="dev in undismissedDetectedDevices"
:key="dev"
class="mesh-transport-option"
:disabled="connectingDevice !== null"
@click="handleConnectDevice(dev)"
>
<span class="mesh-transport-icon">📡</span>
<span class="mesh-transport-label">{{ dev }}</span>
<span class="mesh-transport-meta">{{ connectingDevice === dev ? 'Connecting…' : 'Connect' }}</span>
</button>
</div>
<button class="mesh-transport-cancel" @click="dismissOnboarding">Not now</button>
</div>
</div>
</div>
</template>