` and `outputModuleName`/ `commonWebpackConfig.outputFileName` in
+ * `build.gradle.kts`.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+fun main() {
+ val koinApp = startKoin { modules(webModule()) }
+ Logger.i { "Meshtastic Web — Starting" }
+
+ ComposeViewport("webApp") {
+ val uiViewModel = remember { koinApp.koin.get
() }
+ MeshServiceLifecycle()
+ ThemeAndContent(uiViewModel)
+ }
+}
+
+/** Starts [MeshServiceOrchestrator] on composition and stops it on disposal — same shape as desktopApp's. */
+@Composable
+private fun MeshServiceLifecycle() {
+ val meshServiceController = koinInject()
+ DisposableEffect(Unit) {
+ meshServiceController.start()
+ onDispose { meshServiceController.stop() }
+ }
+}
+
+/**
+ * Resolves the user's theme preference and renders [WebMainScreen]. No locale override on this v0 pass — the browser's
+ * own `Accept-Language`/`navigator.language` already drives Compose Multiplatform resource resolution, and
+ * `uiPrefs.locale`'s manual override (desktopApp's `Locale.setDefault`) has no JS-locale equivalent wired up yet.
+ */
+@Suppress("ViewModelForwarding")
+@Composable
+private fun ThemeAndContent(uiViewModel: UIViewModel) {
+ val uiPrefs = koinInject()
+ val themePref by uiPrefs.theme.collectAsState(initial = -1)
+ val isDarkTheme =
+ when (themePref) {
+ 1 -> false
+ 2 -> true
+ else -> isSystemInDarkTheme()
+ }
+
+ val multiBackstack = rememberMultiBackstack(defaultStartDestination(uiViewModel))
+
+ AppTheme(darkTheme = isDarkTheme) { WebMainScreen(uiViewModel, multiBackstack) }
+}
+
+/** Lands on Connections for first-run / no-device-selected; otherwise on Nodes — same rule desktopApp uses. */
+private fun defaultStartDestination(uiViewModel: UIViewModel): NavKey {
+ val address = uiViewModel.currentDeviceAddressFlow.value
+ return if (address.isNullOrBlank() || address == "n") {
+ TopLevelDestination.Connect.route
+ } else {
+ TopLevelDestination.Nodes.route
+ }
+}
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/db/WebDatabaseManager.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/db/WebDatabaseManager.kt
new file mode 100644
index 0000000000..726d8f5c45
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/db/WebDatabaseManager.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.web.db
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.common.database.DatabaseManager
+
+/**
+ * Minimal [DatabaseManager] for web: exactly one OPFS-backed database exists (`core:database`'s
+ * `SingleDatabaseProvider`), so there is nothing to switch, evict, or associate — every method is a straight reflection
+ * of that single-database reality, not a stubbed-out no-op. android/jvm/iOS's real `DatabaseManager` (`nonWebMain`)
+ * handles legacy-Android-DB migration, LRU eviction across cached per-device databases, and cross-transport merge; none
+ * of that exists here because `SingleDatabaseProvider` itself doesn't support switching devices (see its own KDoc) —
+ * this class can't add multi-device semantics `RadioControllerImpl` needs a [DatabaseManager] to fill in, without
+ * `DatabaseManager` itself gaining a wasmJs implementation.
+ */
+class WebDatabaseManager : DatabaseManager {
+ // No eviction on web — one OPFS file, unbounded by this class. Cap is a placeholder so callers reading it
+ // (settings' cache-limit slider) see a real number, not zero.
+ override val cacheLimit: StateFlow = MutableStateFlow(Int.MAX_VALUE)
+
+ override fun getCurrentCacheLimit(): Int = Int.MAX_VALUE
+
+ override fun setCacheLimit(limit: Int) {
+ // No-op: nothing to evict against on a single, non-switching database.
+ }
+
+ override suspend fun cachedDeviceDbCount(): Int = 1
+
+ override suspend fun switchActiveDatabase(address: String?) {
+ // No-op: there is only ever one database, already active.
+ }
+
+ override suspend fun associateDevice(
+ address: String,
+ nodeNum: Int,
+ deviceId: String?,
+ isSessionActive: () -> Boolean,
+ ) {
+ // No-op: nothing to associate — the single database already serves every address.
+ }
+
+ override fun hasDatabaseFor(address: String?): Boolean = true
+}
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/di/WebKoinModule.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/di/WebKoinModule.kt
new file mode 100644
index 0000000000..a4d5d90c22
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/di/WebKoinModule.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.web.di
+
+// One import per package covers every @Module class in it — Kotlin resolves the right overload by receiver type
+// (see desktopApp's DesktopKoinModule.kt for the identical pattern with CoreDatabaseModule/CoreDatabaseNonWebModule).
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.common.database.DatabaseManager
+import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
+import org.meshtastic.core.common.di.ServiceScope
+import org.meshtastic.core.data.datasource.BundledAssetReader
+import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.repository.AdminController
+import org.meshtastic.core.repository.AppWidgetUpdater
+import org.meshtastic.core.repository.ConnectionStateProvider
+import org.meshtastic.core.repository.LocationRepository
+import org.meshtastic.core.repository.MeshLocationManager
+import org.meshtastic.core.repository.MeshNotificationManager
+import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.core.repository.MessageQueue
+import org.meshtastic.core.repository.MessagingController
+import org.meshtastic.core.repository.NeighborInfoResponseProvider
+import org.meshtastic.core.repository.NodeController
+import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.PlatformAnalytics
+import org.meshtastic.core.repository.QueryController
+import org.meshtastic.core.repository.RadioController
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.ServiceStateWriter
+import org.meshtastic.core.repository.TracerouteResponseProvider
+import org.meshtastic.core.service.RadioControllerImpl
+import org.meshtastic.core.service.ServiceRepositoryImpl
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
+import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
+import org.meshtastic.feature.node.compass.CompassHeadingProvider
+import org.meshtastic.feature.node.compass.MagneticFieldProvider
+import org.meshtastic.feature.node.compass.PhoneLocationProvider
+import org.meshtastic.web.WebBuildConfig
+import org.meshtastic.web.db.WebDatabaseManager
+import org.meshtastic.web.lifecycle.webProcessLifecycle
+import org.meshtastic.web.radio.WebMessageQueue
+import org.meshtastic.web.stub.NoopAppWidgetUpdater
+import org.meshtastic.web.stub.NoopCompassHeadingProvider
+import org.meshtastic.web.stub.NoopLocationRepository
+import org.meshtastic.web.stub.NoopMQTTRepository
+import org.meshtastic.web.stub.NoopMagneticFieldProvider
+import org.meshtastic.web.stub.NoopMeshLocationManager
+import org.meshtastic.web.stub.NoopMeshNotificationManager
+import org.meshtastic.web.stub.NoopMeshWorkerManager
+import org.meshtastic.web.stub.NoopNotificationManager
+import org.meshtastic.web.stub.NoopPhoneLocationProvider
+import org.meshtastic.web.stub.NoopPlatformAnalytics
+import org.meshtastic.core.ble.di.module as coreBleWasmJsModule
+import org.meshtastic.core.common.di.module as coreCommonModule
+import org.meshtastic.core.data.di.module as coreDataModule
+import org.meshtastic.core.database.di.module as coreDatabaseModule
+import org.meshtastic.core.datastore.di.module as coreDatastoreModule
+import org.meshtastic.core.di.di.module as coreDiModule
+import org.meshtastic.core.domain.di.module as coreDomainModule
+import org.meshtastic.core.network.di.module as coreNetworkModule
+import org.meshtastic.core.prefs.di.module as corePrefsModule
+import org.meshtastic.core.repository.di.module as coreRepositoryModule
+import org.meshtastic.core.service.di.module as coreServiceModule
+import org.meshtastic.core.ui.di.module as coreUiModule
+import org.meshtastic.feature.connections.di.module as featureConnectionsModule
+import org.meshtastic.feature.messaging.di.module as featureMessagingModule
+import org.meshtastic.feature.node.di.module as featureNodeModule
+import org.meshtastic.feature.settings.di.module as featureSettingsModule
+
+/**
+ * Koin module for the Web (wasmJs) target — mirrors `desktopApp`'s `desktopModule()`/`desktopPlatformStubsModule()`
+ * shape (the most directly analogous existing precedent: both are "not mobile" hosts that assemble the shared KMP graph
+ * plus a handful of platform stubs).
+ *
+ * Wires in every wasmJs-specific Koin module this whole effort left unregistered, since this is the module meant to
+ * wire them in: [CorePrefsWasmJsModule][org.meshtastic.core.prefs.di.CorePrefsWasmJsModule],
+ * [CoreDatastoreWasmJsModule][org.meshtastic.core.datastore.di.CoreDatastoreWasmJsModule],
+ * [CoreNetworkWasmJsModule][org.meshtastic.core.network.di.CoreNetworkWasmJsModule], and
+ * [CoreBleWasmJsModule][org.meshtastic.core.ble.di.CoreBleWasmJsModule]. `core:database`'s `SingleDatabaseProvider` and
+ * `core:service`'s `NoopTakServerIntegration` need no separate include: both are `@Single`-annotated classes reached by
+ * their own module's existing `@ComponentScan` once this target's compilation includes them (see each class's own KDoc)
+ * — unlike the four modules above, which live in a *separate* `@Module` class from their commonMain counterpart and so
+ * must be listed explicitly, the same way `CorePrefsAndroidModule` sits beside `CorePrefsModule` in `androidApp`'s own
+ * module list. `core:takserver` is deliberately absent — v0 excludes it entirely (`feature:settings`'s own
+ * `nonWebMain`/`wasmJsMain` TAK seam already handles that at the feature layer).
+ */
+fun webModule() = module {
+ includes(
+ org.meshtastic.core.di.di.CoreDiModule().coreDiModule(),
+ org.meshtastic.core.common.di.CoreCommonModule().coreCommonModule(),
+ org.meshtastic.core.datastore.di.CoreDatastoreModule().coreDatastoreModule(),
+ org.meshtastic.core.datastore.di.CoreDatastoreWasmJsModule().coreDatastoreModule(),
+ org.meshtastic.core.prefs.di.CorePrefsModule().corePrefsModule(),
+ org.meshtastic.core.prefs.di.CorePrefsWasmJsModule().corePrefsModule(),
+ // CoreDatabaseModule only — NOT CoreDatabaseNonWebModule, which lives in nonWebMain and isn't even
+ // compiled for this target (androidx.datastore.preferences has no wasmJs variant).
+ org.meshtastic.core.database.di.CoreDatabaseModule().coreDatabaseModule(),
+ org.meshtastic.core.data.di.CoreDataModule().coreDataModule(),
+ org.meshtastic.core.domain.di.CoreDomainModule().coreDomainModule(),
+ org.meshtastic.core.repository.di.CoreRepositoryModule().coreRepositoryModule(),
+ org.meshtastic.core.network.di.CoreNetworkModule().coreNetworkModule(),
+ org.meshtastic.core.network.di.CoreNetworkWasmJsModule().coreNetworkModule(),
+ // CoreBleWasmJsModule only — NOT CoreBleModule, which lives in nonWebMain (Kable has no wasmJs target).
+ org.meshtastic.core.ble.di.CoreBleWasmJsModule().coreBleWasmJsModule(),
+ org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
+ org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
+ org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
+ org.meshtastic.feature.node.di.FeatureNodeModule().featureNodeModule(),
+ org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),
+ org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
+ webPlatformStubsModule(),
+ )
+}
+
+/**
+ * Platform bindings with no commonMain implementation, or that this v0 pass deliberately defers on web. Shaped exactly
+ * like `desktopApp`'s `desktopPlatformStubsModule()`, dropping what v0 doesn't need (map/discovery/docs/
+ * firmware/wifi-provision/intro/widget stubs — those feature modules aren't dependencies of this module at all) and
+ * replacing what's genuinely platform-specific (`RadioController`'s `DatabaseManager`, `MessageQueue`, process
+ * lifecycle, build config).
+ */
+@Suppress("LongMethod")
+private fun webPlatformStubsModule() = module {
+ single { ServiceRepositoryImpl() }
+ single { get() }
+ single { get() }
+ single { get() }
+ single { get() }
+ // RadioTransportFactory: no manual binding — WasmJsRadioTransportFactory (core:network wasmJsMain) is already
+ // `@Single(binds = [RadioTransportFactory::class])`, auto-discovered by CoreNetworkModule's ComponentScan.
+ single { WebDatabaseManager() }
+ single {
+ RadioControllerImpl(
+ serviceRepository = get(),
+ nodeRepository = get(),
+ commandSender = get(),
+ nodeManager = get(),
+ radioInterfaceService = get(),
+ locationManager = get(),
+ packetRepository = lazy { get() },
+ dataHandler = lazy { get() },
+ analytics = get(),
+ meshPrefs = get(),
+ uiPrefs = get(),
+ databaseManager = get(),
+ notificationManager = get(),
+ messageProcessor = lazy { get() },
+ radioConfigRepository = get(),
+ scope = get(),
+ )
+ }
+ single { get() }
+ single { get() }
+ single { get() }
+ single { get() }
+ single { NoopNotificationManager() }
+ single { NoopMeshNotificationManager() }
+ single { NoopPlatformAnalytics() }
+ single { NoopAppWidgetUpdater() }
+ single { NoopMeshWorkerManager() }
+ single { WebMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
+ single { NoopMeshLocationManager() }
+ single { NoopLocationRepository() }
+ // Deliberate override of the real, auto-discovered MQTTRepositoryImpl — same choice desktopApp makes.
+ single { NoopMQTTRepository() }
+ single { NoopCompassHeadingProvider() }
+ single { NoopPhoneLocationProvider() }
+ single { NoopMagneticFieldProvider() }
+ single { NoOpMessageTranslator() }
+
+ single {
+ object : BuildConfigProvider {
+ override val isDebug: Boolean = WebBuildConfig.IS_DEBUG
+ override val applicationId: String = WebBuildConfig.APPLICATION_ID
+ override val versionCode: Int = WebBuildConfig.VERSION_CODE
+ override val versionName: String = WebBuildConfig.VERSION_NAME
+ override val absoluteMinFwVersion: String = WebBuildConfig.ABS_MIN_FW_VERSION
+ override val minFwVersion: String = WebBuildConfig.MIN_FW_VERSION
+ }
+ }
+
+ single(named(PROCESS_LIFECYCLE)) { webProcessLifecycle() }
+
+ // No bundled assets ship with a browser tab; repositories seed from the network instead (same as desktopApp).
+ single { BundledAssetReader { null } }
+}
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/lifecycle/WebProcessLifecycleOwner.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/lifecycle/WebProcessLifecycleOwner.kt
new file mode 100644
index 0000000000..8d0301a035
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/lifecycle/WebProcessLifecycleOwner.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.web.lifecycle
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+
+/**
+ * Synthetic [LifecycleOwner] that stays permanently in [Lifecycle.State.RESUMED] — a browser tab has no Android-style
+ * process lifecycle to observe. Same "always RESUMED" shape as desktopApp's own `DesktopProcessLifecycleOwner`; a real
+ * implementation could listen to the Page Visibility API (`document.visibilityState`) to move to STARTED when the tab
+ * is backgrounded, but nothing in this v0 slice needs that distinction yet.
+ */
+private class WebProcessLifecycleOwner : LifecycleOwner {
+ private val registry = LifecycleRegistry(this)
+
+ init {
+ registry.currentState = Lifecycle.State.RESUMED
+ }
+
+ override val lifecycle: Lifecycle
+ get() = registry
+}
+
+/** The process-wide [Lifecycle], always [Lifecycle.State.RESUMED]. */
+fun webProcessLifecycle(): Lifecycle = WebProcessLifecycleOwner().lifecycle
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/navigation/WebNavigation.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/navigation/WebNavigation.kt
new file mode 100644
index 0000000000..c610f2f03d
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/navigation/WebNavigation.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.web.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation3.runtime.EntryProviderScope
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import org.meshtastic.core.navigation.MapRoute
+import org.meshtastic.core.navigation.MultiBackstack
+import org.meshtastic.core.navigation.SettingsRoute
+import org.meshtastic.core.navigation.TopLevelDestination
+import org.meshtastic.core.ui.viewmodel.UIViewModel
+import org.meshtastic.feature.connections.navigation.connectionsGraph
+import org.meshtastic.feature.messaging.navigation.contactsGraph
+import org.meshtastic.feature.node.navigation.nodesGraph
+import org.meshtastic.feature.settings.navigation.settingsGraph
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.feature.settings.radio.channel.channelsGraph
+
+/**
+ * Registers [NavKey] entry providers for every web (v0) destination — same delegation-to-feature-graph shape as
+ * `desktopApp`'s `desktopNavGraph`, but only the four v0 feature modules: `nodesGraph`, `contactsGraph`,
+ * `settingsGraph`, `channelsGraph`, `connectionsGraph`. No `mapGraph`/`firmwareGraph`/`docsEntries`/`discoveryGraph`/
+ * `wifiProvisionGraph` — those feature modules aren't dependencies of `webApp` at all (AC9).
+ */
+fun EntryProviderScope.webNavGraph(
+ backStack: NavBackStack,
+ uiViewModel: UIViewModel,
+ multiBackstack: MultiBackstack,
+ settingsRadioConfigViewModel: @Composable (SettingsRoute.Settings?) -> RadioConfigViewModel,
+) {
+ nodesGraph(
+ backStack = backStack,
+ scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
+ onHandleDeepLink = uiViewModel::handleDeepLink,
+ onNavigateToConnections = { multiBackstack.navigateTopLevel(TopLevelDestination.Connect.route) },
+ )
+ contactsGraph(
+ backStack = backStack,
+ scrollToTopEvents = uiViewModel.scrollToTopEventFlow,
+ onHandleDeepLink = uiViewModel::handleDeepLink,
+ )
+ settingsGraph(backStack, settingsRadioConfigViewModel)
+ channelsGraph(backStack)
+ connectionsGraph(backStack)
+
+ // Defensive fallback for MapRoute.Map, reachable via DeepLinkRouter's "map" URI mapping regardless of platform
+ // (see core:navigation's DeepLinkRouter.kt) even though feature:map isn't a dependency here. Renders nothing,
+ // matching feature:settings' identical TakModuleConfigContent fallback for a deep-link-reachable destination
+ // this platform can't serve — an honest no-op, not a crash.
+ entry {}
+}
+
+/** v0's visible top-level tabs: every [TopLevelDestination] except Map (no `mapGraph` entry provider above). */
+val webVisibleDestinations: List =
+ TopLevelDestination.entries.filter { it != TopLevelDestination.Map }
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/radio/WebMessageQueue.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/radio/WebMessageQueue.kt
new file mode 100644
index 0000000000..f66daa2e65
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/radio/WebMessageQueue.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.web.radio
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.repository.MessageQueue
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PersistedPacketId
+import org.meshtastic.core.repository.RadioController
+
+/**
+ * Web implementation of [MessageQueue] — identical shape to `desktopApp`'s `DesktopMessageQueue` (both are
+ * single-process, in-tab hosts with no background delivery service to hand off to): send immediately if connected,
+ * otherwise leave the packet queued for `MeshConnectionManager` to retry once a connection re-establishes.
+ */
+class WebMessageQueue(
+ private val packetRepository: PacketRepository,
+ private val radioController: RadioController,
+ dispatchers: CoroutineDispatchers,
+) : MessageQueue {
+ private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
+
+ override suspend fun enqueue(persistedId: PersistedPacketId) {
+ scope.launch {
+ if (persistedId.uuid <= 0L) return@launch
+
+ if (radioController.connectionState.value != ConnectionState.Connected) {
+ return@launch
+ }
+
+ val claimed = packetRepository.claimQueuedPacket(persistedId) ?: return@launch
+ if (claimed.packet.status != MessageStatus.QUEUED) return@launch
+
+ try {
+ radioController.sendMessage(claimed.packet)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "Failed to send packet ${claimed.packet.id}, re-queuing" }
+ packetRepository.rollbackEnroutePacket(claimed.id)
+ if (e is CancellationException) throw e
+ }
+ }
+ }
+}
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/stub/NoopStubs.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/stub/NoopStubs.kt
new file mode 100644
index 0000000000..5aed28d4d4
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/stub/NoopStubs.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+@file:Suppress("EmptyFunctionBlock", "TooManyFunctions")
+
+package org.meshtastic.web.stub
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.repository.AppWidgetUpdater
+import org.meshtastic.core.repository.DataPair
+import org.meshtastic.core.repository.Location
+import org.meshtastic.core.repository.LocationRepository
+import org.meshtastic.core.repository.MeshLocationManager
+import org.meshtastic.core.repository.MeshNotificationManager
+import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.core.repository.Notification
+import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.PersistedPacketId
+import org.meshtastic.core.repository.PlatformAnalytics
+import org.meshtastic.feature.node.compass.CompassHeadingProvider
+import org.meshtastic.feature.node.compass.HeadingState
+import org.meshtastic.feature.node.compass.MagneticFieldProvider
+import org.meshtastic.feature.node.compass.PhoneLocationProvider
+import org.meshtastic.feature.node.compass.PhoneLocationState
+import org.meshtastic.proto.ClientNotification
+import org.meshtastic.proto.MqttClientProxyMessage
+import org.meshtastic.proto.Telemetry
+import org.meshtastic.mqtt.ConnectionState as MqttConnectionState
+
+/**
+ * No-op stub implementations for platform-specific interfaces with no commonMain implementation, or that this v0 pass
+ * deliberately defers on web. Mirrors desktopApp's own `stub/NoopStubs.kt`/`CompassStubs.kt` — same interfaces, same
+ * "no sensor/OS integration on this platform" reasoning, just no OS calls to make since web has none of these either.
+ */
+private const val TAG = "WebNoopStub"
+
+// region Notification stubs — browser Notification API exists but is deliberately deferred (permission prompts,
+// service-worker plumbing); real integration is a future pass, not this v0 slice.
+
+class NoopNotificationManager : NotificationManager {
+ override suspend fun dispatch(notification: Notification): Boolean = false
+
+ override fun cancel(id: Int) {}
+
+ override fun cancelAll() {}
+}
+
+class NoopMeshNotificationManager : MeshNotificationManager {
+ override fun clearNotifications() {}
+
+ override fun initChannels() {}
+
+ override fun updateServiceStateNotification(state: ConnectionState, telemetry: Telemetry?) {}
+
+ override suspend fun updateMessageNotification(
+ contactKey: String,
+ name: String,
+ message: String,
+ isBroadcast: Boolean,
+ channelName: String?,
+ isSilent: Boolean,
+ ) {}
+
+ override suspend fun updateWaypointNotification(
+ contactKey: String,
+ name: String,
+ message: String,
+ waypointId: Int,
+ isSilent: Boolean,
+ ) {}
+
+ override suspend fun updateReactionNotification(
+ contactKey: String,
+ name: String,
+ emoji: String,
+ isBroadcast: Boolean,
+ channelName: String?,
+ isSilent: Boolean,
+ ) {}
+
+ override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
+
+ override fun showNewNodeSeenNotification(node: Node) {}
+
+ override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
+
+ override fun showClientNotification(clientNotification: ClientNotification) {}
+
+ override suspend fun cancelMessageNotification(contactKey: String) {}
+
+ override fun cancelLowBatteryNotification(node: Node) {}
+
+ override fun clearClientNotification(notification: ClientNotification) {}
+}
+
+// endregion
+
+// region Platform / widget / worker stubs (Android-only concepts)
+
+class NoopPlatformAnalytics : PlatformAnalytics {
+ override fun track(event: String, vararg properties: DataPair) {}
+
+ override fun setDeviceAttributes(firmwareVersion: String, model: String) {}
+
+ override val isPlatformServicesAvailable: Boolean = false
+}
+
+class NoopAppWidgetUpdater : AppWidgetUpdater {
+ override suspend fun updateAll() {}
+}
+
+class NoopMeshWorkerManager : MeshWorkerManager {
+ override fun enqueueSendMessage(persistedId: PersistedPacketId) {}
+}
+
+// endregion
+
+// region Location stubs — a real implementation would wrap navigator.geolocation; deferred, matching desktop's own
+// "no consumer needs real location on web yet" posture.
+
+class NoopMeshLocationManager : MeshLocationManager {
+ override fun start(
+ scope: kotlinx.coroutines.CoroutineScope,
+ sendPositionFn: suspend (org.meshtastic.proto.Position) -> Unit,
+ ) {}
+
+ override fun restart() {}
+
+ override fun stop() {}
+}
+
+class NoopLocationRepository : LocationRepository {
+ override val receivingLocationUpdates = MutableStateFlow(false)
+
+ override fun getLocations(): Flow = emptyFlow()
+}
+
+// endregion
+
+// region MQTT stub — MQTTRepositoryImpl (core:network, @Single) is already auto-discovered for wasmJs via
+// CoreNetworkModule's ComponentScan; overriding it here mirrors desktopApp's own deliberate override (MQTT proxying
+// over a phone/desktop's own network stack has no obvious web analogue yet — same deferred posture, not a regression).
+
+class NoopMQTTRepository : MQTTRepository {
+ override fun disconnect() {}
+
+ override val proxyMessageFlow: Flow = emptyFlow()
+
+ override fun publish(topic: String, data: ByteArray, retained: Boolean) {}
+
+ override val connectionState = MutableStateFlow(MqttConnectionState.Disconnected.Idle)
+}
+
+// endregion
+
+// region Compass/GPS stubs — browser has navigator.geolocation but no compass/magnetometer API with broad support;
+// deferred, matching desktop's identical "no sensor" posture.
+
+class NoopCompassHeadingProvider : CompassHeadingProvider {
+ override fun headingUpdates(): Flow = flowOf(HeadingState(hasSensor = false))
+}
+
+class NoopPhoneLocationProvider : PhoneLocationProvider {
+ override fun locationUpdates(): Flow =
+ flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false))
+}
+
+class NoopMagneticFieldProvider : MagneticFieldProvider {
+ override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
+}
+
+// endregion
diff --git a/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/ui/WebMainScreen.kt b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/ui/WebMainScreen.kt
new file mode 100644
index 0000000000..6508cef0e9
--- /dev/null
+++ b/webApp/src/wasmJsMain/kotlin/org/meshtastic/web/ui/WebMainScreen.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.web.ui
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import org.meshtastic.core.navigation.MultiBackstack
+import org.meshtastic.core.ui.component.MeshtasticAppShell
+import org.meshtastic.core.ui.component.MeshtasticNavDisplay
+import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
+import org.meshtastic.core.ui.viewmodel.UIViewModel
+import org.meshtastic.feature.settings.navigation.rememberSettingsRadioConfigViewModelProvider
+import org.meshtastic.web.navigation.webNavGraph
+import org.meshtastic.web.navigation.webVisibleDestinations
+
+/**
+ * Web main screen — same assembly as `desktopApp`'s `DesktopMainScreen` (shared
+ * [MeshtasticAppShell] + [MeshtasticNavigationSuite] + [MeshtasticNavDisplay]), wired to [webNavGraph] instead of
+ * `desktopNavGraph` and [webVisibleDestinations] instead of the full
+ * [org.meshtastic.core.navigation.TopLevelDestination] set (no Map tab — v0 scope, AC9).
+ */
+@Suppress("ViewModelForwarding")
+@Composable
+fun WebMainScreen(uiViewModel: UIViewModel, multiBackstack: MultiBackstack, modifier: Modifier = Modifier) {
+ val backStack = multiBackstack.activeBackStack
+ val settingsRadioConfigViewModelProvider = rememberSettingsRadioConfigViewModelProvider(backStack)
+
+ Surface(modifier = modifier.fillMaxSize()) {
+ MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = uiViewModel) {
+ MeshtasticNavigationSuite(
+ multiBackstack = multiBackstack,
+ uiViewModel = uiViewModel,
+ modifier = Modifier.fillMaxSize(),
+ visibleDestinations = webVisibleDestinations,
+ ) {
+ val provider =
+ entryProvider {
+ webNavGraph(
+ backStack = backStack,
+ uiViewModel = uiViewModel,
+ multiBackstack = multiBackstack,
+ settingsRadioConfigViewModel = settingsRadioConfigViewModelProvider,
+ )
+ }
+ MeshtasticNavDisplay(
+ multiBackstack = multiBackstack,
+ entryProvider = provider,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ }
+}
diff --git a/webApp/src/wasmJsMain/resources/index.html b/webApp/src/wasmJsMain/resources/index.html
new file mode 100644
index 0000000000..20a0cee2a9
--- /dev/null
+++ b/webApp/src/wasmJsMain/resources/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Meshtastic
+
+
+
+
+
+
+
+
diff --git a/webApp/webpack.config.d/coop-coep-headers.js b/webApp/webpack.config.d/coop-coep-headers.js
new file mode 100644
index 0000000000..170c683ca5
--- /dev/null
+++ b/webApp/webpack.config.d/coop-coep-headers.js
@@ -0,0 +1,12 @@
+// core:database's OPFS-backed SQLite driver (WebWorkerSQLiteDriver) needs a cross-origin-isolated page — verified
+// empirically in this effort's Step 0b spike (danysantiago/room-web-demo), which required serving with these two
+// headers for the OPFS path to work at all. `config.devServer` is undefined during the production webpack build
+// (wasmJsBrowserDistribution) — guard it, don't set unconditionally, or that build crashes (the exact bug this
+// spike found and fixed upstream).
+if (config.devServer) {
+ config.devServer.headers = {
+ ...(config.devServer.headers || {}),
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ };
+}