diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index c60358c184..1183bbbb38 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -28,6 +28,19 @@ kotlin {
// Without this, Res.readBytes() throws MissingResourceException at runtime.
android { androidResources.enable = true }
+ // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). No custom
+ // hierarchy group is needed for MAIN, same shape as core:common/core:model/core:repository/core:service:
+ // every one of this module's own dependencies (core/*, coil, jetbrains-markdown, qrcode-kotlin, every
+ // compose-multiplatform-*/jetbrains-* artifact) is confirmed to publish a wasmJs variant, and the only
+ // three commonMain files with `expect`/`actual` (PlatformUtils.kt, ClipboardUtils.kt, HtmlUtils.kt) plus
+ // three more found while verifying this pass (ScreenUtils.kt, DynamicColorScheme.kt,
+ // DropDownPreference.kt's enumEntriesOf/isDeprecatedEnumEntry) all gained real wasmJsMain actuals below —
+ // none of them needed a native-only dependency hidden from wasmJs, so no `applyHierarchyTemplate` call is
+ // needed here (calling it a second time, after `meshtastic.kmp.jvm.android`'s own, would fail — Gradle
+ // only allows one per project — which is why core:common/core:model keep the plugin and skip the call too).
+ @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
+ wasmJs()
+
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
@@ -73,11 +86,44 @@ kotlin {
androidMain.dependencies { implementation(libs.androidx.activity.compose) }
- commonTest.dependencies {
- implementation(projects.core.testing)
- implementation(libs.junit)
- implementation(libs.compose.multiplatform.ui.test)
+ // `rememberOpenUrl`'s wasmJs actual (`window.open`) is the only wasmJsMain file needing a JS-facing
+ // dependency; everything else in wasmJsMain is either a pure Kotlin no-op or (createClipEntry,
+ // KeepScreenOn) uses only Compose Multiplatform's own API / a hand-declared `external` (matching
+ // core:ble's `WebBluetoothApi.kt` idiom for the Screen Wake Lock API kotlinx-browser doesn't bind).
+ wasmJsMain.dependencies { implementation(libs.kotlinx.browser) }
+
+ commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) }
+
+ // TEST only: core:testing has no wasmJs target (same gap core:ble/core:database/core:network/
+ // core:data/core:service all hit) — confirmed via grep that exactly 3 of this module's 27 commonTest
+ // files import org.meshtastic.core.testing (ConnectionsViewModelTest.kt, ProtoExtensionsTest.kt,
+ // SharedContactViewModelTest.kt); those 3 move to nonWebTest, the other 24 (including every
+ // runComposeUiTest-based UI test — compose-multiplatform-ui-test itself does publish a wasmJs variant)
+ // stay in commonTest. `libs.junit` also moves here: grep found zero `org.junit.*` usage anywhere in
+ // this module's tests (vestigial, presumably pre-dating the migration to `kotlin.test`), and it is a
+ // plain non-KMP jar with no wasmJs metadata at all, so leaving it in commonTest risked being a second,
+ // indistinguishable cause of any wasmJs test-resolution failure.
+ //
+ // No applyHierarchyTemplate call happens in this script (see the MAIN comment above), so the default
+ // template's shared intermediate test source sets are not synchronously available by name this early
+ // (confirmed: core:ui has no `android { withHostTest {} }` call at all, so `androidHostTest` doesn't
+ // even exist — unlike core:service/core:data, which all call `withHostTest {}` and could `getByName`
+ // it directly). `matching{}.configureEach{}` degrades to a harmless no-op for whichever of these leaf
+ // test source sets isn't registered, instead of throwing.
+ val nonWebTest by creating {
+ dependsOn(commonTest.get())
+ dependencies {
+ implementation(projects.core.testing)
+ implementation(libs.junit)
+ }
}
+ matching {
+ it.name == "jvmTest" ||
+ it.name == "androidHostTest" ||
+ it.name == "iosArm64Test" ||
+ it.name == "iosSimulatorArm64Test"
+ }
+ .configureEach { dependsOn(nonWebTest) }
jvmTest.dependencies { implementation(compose.desktop.currentOs) }
}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt b/core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt
similarity index 100%
rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt
rename to core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt b/core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt
similarity index 100%
rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt
rename to core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
similarity index 100%
rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
rename to core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/component/EnumReflection.wasmJs.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/component/EnumReflection.wasmJs.kt
new file mode 100644
index 0000000000..04717112a7
--- /dev/null
+++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/component/EnumReflection.wasmJs.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.core.ui.component
+
+// jvmAndroidMain's actuals use `java.lang.Class` reflection (`declaringJavaClass.enumConstants`, `getField(...)`) to
+// enumerate an enum's entries and check for `@Deprecated` from just an instance, with no reified type parameter to
+// call `enumValues()`/`enumEntries()` instead. Kotlin/Wasm has no `java.lang.Class`-equivalent reflection
+// surface at all (same gap iOS's Kotlin/Native actual has, which is why it takes the identical fallback below) — and
+// changing `DropDownPreference`'s public signature to take a reified type parameter instead of an instance is a
+// commonMain API change well outside this pass's scope. Matches iOS exactly: `DropDownPreference`'s enum-instance
+// overload renders with no selectable items on this target, same as on iOS today — a pre-existing gap this pass
+// inherits rather than introduces. Callers who need reliable enum options in a dropdown can use the `List>` or `List>` overloads instead, which don't call through this expect at all.
+internal actual fun > enumEntriesOf(selectedItem: T): List = emptyList()
+
+internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false
diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/theme/DynamicColorScheme.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/theme/DynamicColorScheme.kt
new file mode 100644
index 0000000000..b99c1fb0b7
--- /dev/null
+++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/theme/DynamicColorScheme.kt
@@ -0,0 +1,23 @@
+/*
+ * 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.core.ui.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.runtime.Composable
+
+/** Web — dynamic (Material You / wallpaper-derived) color is an Android System-only concept. Matches the JVM actual. */
+@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null
diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
new file mode 100644
index 0000000000..7e1b50bf7e
--- /dev/null
+++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
@@ -0,0 +1,33 @@
+/*
+ * 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.core.ui.util
+
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.platform.ClipEntry
+
+// Web has no native clip-data object comparable to Android's `ClipData`/AWT's `StringSelection` — there is no
+// `ClipEntry(nativeType)` constructor overload for wasmJs at all (confirmed against Compose Multiplatform's own
+// wasmJs `ui` klib: only the common `ClipEntry.withPlainText` factory is present in that target's metadata). That
+// factory is real, not a fallback: writing the returned `ClipEntry` to the system clipboard still goes through the
+// genuine `navigator.clipboard` Async Clipboard API — that's handled inside Compose Multiplatform's own
+// `Clipboard.setClipEntry()` implementation for this target, which every call site here already goes through (see
+// `CopyIconButton.kt`, `QrDialog.kt`, etc.) — so no extra `navigator.clipboard.writeText` interop is needed here.
+// `label`/`sensitive` have no browser equivalent (there is no OS-level "don't show a paste-preview toast" concept, nor
+// a "mark as secret" clipboard flag in the Clipboard API), so — like the JVM/AWT actual's `sensitive` — both are
+// accepted and ignored.
+@OptIn(ExperimentalComposeUiApi::class)
+actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry = ClipEntry.withPlainText(text)
diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/HtmlUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/HtmlUtils.kt
new file mode 100644
index 0000000000..442077580d
--- /dev/null
+++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/HtmlUtils.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.core.ui.util
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextLinkStyles
+
+// androidMain's actual (`AnnotatedString.fromHtml`) is a genuinely Android-specific real HTML renderer — it isn't a
+// common Compose Multiplatform API, so it isn't available here. The desktop JVM actual, despite running on a full
+// desktop JVM with far more capability than a browser sandbox, *also* takes the plain-text shortcut below rather than
+// pulling in an HTML parser — that's the established bar for "good enough" fidelity on every non-Android platform in
+// this codebase today, so wasmJs matches it rather than reaching for `org.jetbrains:markdown`-style HTML parsing
+// (this module already depends on that library for actual Markdown, but it doesn't parse HTML) or hand-rolling a
+// `dangerouslySetInnerHTML`-style DOM injection, which would also sidestep Compose's own text layout/selection.
+/** Web stub — returns the raw HTML as plain text (no HTML rendering), matching the JVM/Desktop and iOS actuals. */
+actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html)
diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
new file mode 100644
index 0000000000..b315bac263
--- /dev/null
+++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -0,0 +1,144 @@
+/*
+ * 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("TooManyFunctions")
+
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import kotlinx.browser.window
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.await
+import kotlinx.coroutines.awaitCancellation
+import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.common.util.CommonUri
+import kotlin.js.Promise
+
+// Everything below except `rememberOpenUrl` and `KeepScreenOn` mirrors iosMain/NoopStubs.kt's exact choices — same
+// reasoning as iOS: these are Android-system-settings concepts (NFC/Bluetooth/Wi-Fi/location/app settings, ahead-of-
+// time permission-state checks) with no browser equivalent, or platform features (toasts, native file/document
+// pickers) whose nearest browser analog (a File System Access API picker, gated behind a user gesture and Chromium-
+// only) isn't a drop-in replacement for the contract these expects describe. `bleScanRequiresLocationServices` and the
+// four `is*Disabled`/`is*Unavailable` checks are `false` for the same reason they are on iOS: the concept of an ahead-
+// of-time "is Bluetooth/GPS/Wi-Fi disabled at the system level" query doesn't exist for a sandboxed browser tab.
+
+@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {}
+
+@Composable actual fun rememberShowToast(): suspend (String) -> Unit = { _ -> }
+
+@Composable actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -> }
+
+@Composable actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ -> }
+
+/** Web — opens the URL in a new browser tab via `window.open`, so the running app's own tab/state is left intact. */
+@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { url -> window.open(url, "_blank", "") }
+
+@Composable
+actual fun rememberSaveFileLauncher(
+ onUriReceived: (CommonUri) -> Unit,
+): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
+
+@Composable
+actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> }
+
+@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit = {}
+
+@Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { _, _ -> null }
+
+/**
+ * Web — backed by the real, documented Screen Wake Lock API
+ * (https://www.w3.org/TR/screen-wake-lock/#the-wakelock-interface). `kotlinx-browser` has no binding for it (checked:
+ * absent from its generated `org.w3c.dom` sources), so it's declared fresh below, following the same `external
+ * interface : JsAny` + private `external val navigator` idiom `core:ble`'s `WebBluetoothApi.kt` established for
+ * `navigator.bluetooth`.
+ *
+ * Requesting the lock requires a secure context (HTTPS) and a visible document; a request that is rejected for any
+ * reason (unsupported browser, insecure origin, tab not visible yet) is swallowed here — the screen simply behaves as
+ * if `KeepScreenOn` had never been called, the same no-op fallback posture every platform gap in this file takes.
+ *
+ * Known gap, not fixed here: per spec the browser releases the lock automatically the instant the document is hidden
+ * (tab backgrounded/minimized), and this does not re-request it when the tab regains visibility — a deliberate scope
+ * cut for this pass (re-acquiring correctly needs a `visibilitychange` listener, which `kotlinx-browser` also doesn't
+ * bind), not an oversight. Android's own `view.keepScreenOn` is similarly revocable by the OS outside this code's
+ * control, so this is a difference of degree, not of kind.
+ */
+@Suppress("TooGenericExceptionCaught")
+@Composable
+actual fun KeepScreenOn(enabled: Boolean) {
+ LaunchedEffect(enabled) {
+ if (!enabled) return@LaunchedEffect
+ // The request and the eventual release live in the same coroutine (rather than a DisposableEffect firing a
+ // separate `launch` for the request) so a fast dispose can never race ahead of an in-flight `request()` and
+ // leak the lock — `awaitCancellation()` blocks this coroutine open until the effect leaves composition or
+ // `enabled` flips, and `finally` then runs release synchronously on that same cancellation, never orphaned.
+ val sentinel =
+ try {
+ navigator.wakeLock?.request("screen")?.await()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ // See kdoc: unsupported/insecure-context/not-yet-visible rejection is an expected, silent no-op.
+ null
+ }
+ try {
+ awaitCancellation()
+ } finally {
+ sentinel?.release()
+ }
+ }
+}
+
+@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
+
+@Composable actual fun rememberOpenBluetoothSettings(): () -> Unit = {}
+
+@Composable actual fun rememberOpenWifiSettings(): () -> Unit = {}
+
+actual val bleScanRequiresLocationServices: Boolean = false
+
+@Composable actual fun isGpsDisabled(): Boolean = false
+
+@Composable actual fun isBluetoothDisabled(): Boolean = false
+
+@Composable actual fun isWifiUnavailable(): Boolean = false
+
+@Composable actual fun rememberOpenAppSettings(): () -> Unit = {}
+
+@Composable actual fun rememberLocationPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberBluetoothPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberNotificationPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberLocalNetworkPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+@Composable actual fun rememberCameraPermissionState(): PermissionUiState = grantedPermissionUiState()
+
+private external interface JsWakeLockSentinel : JsAny {
+ fun release(): Promise
+}
+
+private external interface JsWakeLock : JsAny {
+ fun request(type: String): Promise
+}
+
+private external interface JsNavigatorWakeLock : JsAny {
+ val wakeLock: JsWakeLock?
+}
+
+/** Access to `navigator.wakeLock`, isolated here — see the `KeepScreenOn` kdoc above. */
+private external val navigator: JsNavigatorWakeLock
diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt
new file mode 100644
index 0000000000..2677f00bd7
--- /dev/null
+++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.core.ui.util
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Web no-op — unlike Screen Wake Lock, there is no browser API to read or set the physical display's brightness at all
+ * (a page can only fake it by dimming its own content with an overlay, which is a UI concern, not a platform primitive
+ * this function should reach for). Matches the JVM/Desktop actual.
+ */
+@Composable
+actual fun SetScreenBrightness(brightness: Float) {
+ // No-op on Web — no browser API for physical display brightness exists.
+}