Skip to content

feat(ui): StatusSurface AA legibility + node-details signing/transport polish#5985

Merged
jamesarich merged 17 commits into
mainfrom
feat/status-surface
Jun 28, 2026
Merged

feat(ui): StatusSurface AA legibility + node-details signing/transport polish#5985
jamesarich merged 17 commits into
mainfrom
feat/status-surface

Conversation

@jamesarich

@jamesarich jamesarich commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #5984. Addresses the accessibility thread on design#113 (thebentern: status-color contrast; olm3c: it's the shared token), and carries the node-details/security UI polish that grew out of it.

Why

The status-color tokens (StatusGreen/Yellow/Orange/Red) are bright indicator colors — they only clear WCAG AA against a near-black or near-white surface, and fail in the mid-tone range. As colored text they fail on both the per-node-tinted message bubble and the node-list card surface.

Rather than mutate each color per surface (closed #5984), give status-colored content a constant dark backing so the tokens keep their true values and stay legible anywhere — then clean up the surrounding node-details/security UI while we're in there.

What it looks like

Signed broadcast bubble Node details Signed node in a list
bubble details list

🌟 What changed

Legibility (StatusSurface)

  • Reusable translucent near-black scrim chip (StatusScrim, alpha at the AA floor) + contrastRatio() WCAG helper. Unit test proves all four status tokens meet AA (4.5:1) on the scrim.
  • The chip backs only the status-colored element, never the surrounding row — neutral content (white transport/hop icons, distance, bearing, battery %) stays bare.
  • Message bubble: SNR/RSSI in one chip, signed shield in its own chip, transport/hop bare. Bubble surface + border now share the node tint/stroke with the node card (NodeSurface).
  • Node items (NodeItem / NodeItemCompact): signal-quality grouped in one chip; green "online" last-heard backed only when online (shared StatusAwareLastHeard).

Node details / security

  • Signed node (XEdDSA) row with a green, tappable shield that opens a plain-language explanation dialog (SignedNodeDialog); shares the verification row with Supported.
  • New Transport row — how the node was last heard (LoRa / MQTT / UDP / API), driven by Node.lastTransport. Icon+label mapping centralised in a shared transportInfo helper so the list badge (TransportIcon) and the details row can't drift. Hidden for internally-generated (own) packets.
  • Local-node badge mirrors the nav-bar connection icon and blinks on mesh activity (LocalMeshActivity provided as a Flow, so per-packet activity doesn't recompose the tree).

Scope / deliberate exclusions

  • Status-colored icons that are shape-redundant (battery, favorite star, key/lock) are left bare — color isn't the sole channel, and a chip behind a lone icon reads wrong.
  • Other status-color sites (nav connection dot, bluetooth signal, traceroute) not yet touched; StatusSurface is the reusable primitive for them when warranted.
  • "Transport" only broadens the old MQTT-only cell; it does not add new per-transport behaviour.

🧪 Testing performed

  • ./gradlew :core:ui:allTests --tests "*StatusSurfaceTest*" — all four status tokens meet AA on StatusScrim.
  • ./gradlew spotlessCheck detekt — clean.
  • ./gradlew :screenshot-tests:updateDebugScreenshotTest — references regenerated (bubble, node-item, node-details signed; light/dark).

🤖 Generated with Claude Code

…urface

Review direction (design#113): the status-color tokens (StatusGreen/
Yellow/Orange/Red) are bright indicator colors that only clear WCAG AA
against a near-black or near-white surface — never a mid-tone. On a
per-node-tinted message bubble they're hard to read.

Rather than mutate each color's lightness per surface, give status-colored
content a constant dark backing so the tokens keep their TRUE values and
stay legible anywhere:

- StatusSurface: a reusable opaque near-black scrim chip (StatusScrim).
  Opaque by necessity — a translucent scrim composites with the surface
  behind it and can't guarantee the ratio. Unit test proves all four
  status tokens meet AA (4.5:1) on the scrim.
- contrastRatio() helper (WCAG) in core/ui theme.
- MessageItem wraps the bubble metadata cluster (SNR/RSSI/transport/
  signed shield) in StatusSurface; colors render at their true token
  values inside.

StatusSurface is reusable for the other status-color sites (signal/
battery/node indicators) — those can adopt it incrementally; this change
only touches the message bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

✅ Preview staleness check passed

Preview and screenshot references are up to date.

@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

📄 Docs staleness check — advisory

This PR modifies user-facing UI source files but does not update any page under docs/en/user/ or docs/en/developer/.

⚠️ Doc changes propagate to 3 consumers: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in docs/en/ automatically flows to all three.

Changed source files:

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeSignedStatusIcon.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeStatusIcons.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeSurface.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/StatusSurface.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/TransportIcon.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/ColorContrast.kt
core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMeshActivity.kt
feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponents.kt
feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt

What to check:

Changed area Likely doc page
feature/messaging/ docs/en/user/messages-and-channels.md
feature/node/ docs/en/user/nodes.md or docs/en/user/node-metrics.md
feature/map/ docs/en/user/map-and-waypoints.md
feature/connections/ docs/en/user/connections.md
feature/settings/ docs/en/user/settings-radio-user.md or docs/en/user/settings-module-admin.md
feature/firmware/ docs/en/user/firmware.md
feature/intro/ docs/en/user/onboarding.md
feature/discovery/ docs/en/user/discovery.md
feature/docs/ Internal docs infrastructure
core/ui/ docs/en/developer/codebase.md or component-specific user pages

New page checklist (if adding a new doc page):

  1. Create the .md file in docs/en/user/ or docs/en/developer/ with last_updated frontmatter
  2. Register in DocBundleLoader.kt with string resources (in-app browser)
  3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this check.

Cross-platform note: This check is advisory while doc coverage matures. Both Android and Apple repos use the same skip-docs-check label and advisory severity. See meshtastic/design standards for shared conventions.

… node items

Refine StatusSurface usage to the principle: the scrim chip wraps ONLY
the elements actually drawn in a status color — neutral content (white
transport/hop icons, distance, bearing, battery %, etc.) stays bare.

- MessageItem bubble: SNR/RSSI in one chip, the signed shield in its own
  chip; the white transport/hop icons are no longer on the scrim.
- NodeItem: SNR/RSSI/quality grouped into a single chip cell in the
  metrics grid; the green "online" last-heard backed only when online.
- NodeItemCompact: signal-quality segment chipped; green last-heard
  backed only when online.

Status-colored icons that are shape-redundant (battery, favorite star,
key/lock) are intentionally left bare — color isn't the sole channel and
a chip behind a lone icon reads wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jamesarich jamesarich changed the title feat(ui): StatusSurface — keep status colors AA-legible on any surface feat(ui): StatusSurface — keep status colors AA-legible (message bubble + node items) Jun 27, 2026
jamesarich and others added 9 commits June 27, 2026 15:49
…e, tighter padding

Polish from review: the solid-black chip read harsh. Make the scrim
slightly translucent so the surface tint shows through, switch to
CircleShape (a pill for a row, a circle behind a solo icon like the
signed shield), and tighten the padding.

Translucency composites with the surface, so the strict contrast
guarantee relaxes for the darkest token: green (the common signed/good
case) still clears AA text contrast even over a light card; the worst
case (red) holds the 3:1 graphical floor. Test updated to assert the
composited guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt + border)

Make the message bubble and node card use one shared surface treatment
(new NodeSurface: nodeTintedContainer + nodeBorderStroke) — the more
accessible of the two. The bubble previously used a 40%-saturated node
fill with a thin opaque outline; it now uses the node card's faint 8%
wash over the neutral surface plus a node-colored 1.5dp border. That
keeps the surface/onSurface AA pairing for the message text while still
reading as "this node", and makes the two surfaces visually consistent.

Selection/filtered states map to tint strength (emphasized/muted) +
border emphasis instead of fill opacity. Node cards are unchanged
(refactored onto the shared helper, identical output).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… AA floor)

The bright status tokens need a dark backing, so there isn't much room:
over a pure-white worst case ~0xD4 is the floor before red drops under
3:1 / green under 4.5:1. 0xD6 keeps a small margin (green 4.68, red 3.11
over white) while reading more translucent than 0xDB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (0xCD)

- NodeItem header: NodeKeyStatusIcon + the XEdDSA signed shield (when
  node.signsPackets) now share one StatusSurface chip, extracted into a
  NodeSecurityIcons composable. The key icon is always status-colored, so
  the scrim fixes its contrast on the light card too.
- Bubble: signed shield leads the metadata row.
- StatusScrim alpha 0xD4 → 0xCD — the floor over the surfaces the chip
  ACTUALLY sits on (light card surface at max node tint) rather than pure
  white, which it never sits on. Test now composites over that real
  backdrop; green still ≥4.5 (AA text), red ≥3.0 (AA graphical), zero
  margin by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…node items

NodeItemSignedPreview renders a node with signsPackets=true; the green
shield-check now shows beside the key-status icon in the header security
chip. Registered as ScreenshotNodeItemSigned (light/dark) for regression
coverage of the signed state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NodeItemSignedPreview now sets a public key so the security chip shows
the fully-trusted combo: green lock (PKC) + green signed shield.

Also corrects node screenshots that a prior run had regenerated with a
stray status-icon size mutation; re-rendered from clean source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s on activity)

ThisNodeStatusBadge now uses AnimatedConnectionsNavIcon — the same
blinking connection icon as the nav bar — instead of the static one.

The mesh-activity stream reaches the badge via a new LocalMeshActivity
CompositionLocal that holds the FLOW (stable ref), provided once at the
app root from uiViewModel.meshActivity. Providing the flow (not a
collected value) costs no recomposition; only this single local-node
badge collects it, at the leaf, animating in the draw phase. Falls back
to the static ConnectionsNavIcon in previews/tests (local unset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- NodeItemCompact: show the key-status + signed shield in a StatusSurface
  chip (via NodeSecurityIcons, now internal with an iconSize param,
  reused at 18dp) — matching NodeItem.
- MessageItem: the quoted-reply header now uses nodeTintedContainer
  (emphasized tint) instead of an 80%-saturated node fill, so its
  onSurface text keeps the AA pairing like the rest of the bubble.
- NodeStatusIcons: connection badge ordered last; status badges 20dp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "online" last-heard chip (StatusGreen + scrim when online, plain
otherwise) was duplicated in NodeItem and NodeItemCompact. Extract it to
one internal StatusAwareLastHeard composable and call it from both. Pure
refactor — identical render (0 screenshot changes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jamesarich and others added 5 commits June 27, 2026 18:10
…details

- New NodeSignedStatusIcon: the XEdDSA shield abstracted into a reusable,
  tappable component (like NodeKeyStatusIcon) — tapping opens a
  plain-language explanation dialog (new security_signed_node_help string).
- NodeSecurityIcons is now public, renders NodeSignedStatusIcon, and takes
  a modifier.
- Node details: replaced the labeled "Signed node" row with the
  NodeSecurityIcons key+shield cluster (both icons now tap-for-detail);
  dropped SignedNodeRow + its imports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cluster)

Keep the labeled "Signed node / Verified automatically" row in node
details; the NodeSecurityIcons key+shield cluster stays in the node-list
rows only. NodeSignedStatusIcon (tappable shield) remains in use via the
list cluster. Signed-detail preview given a key (green lock + shield).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SignedNodeRow now shows the plain-language help beneath "Verified
automatically" (new optional InfoItem supportingText slot), so node
details is self-explanatory without a dialog — the tap-for-dialog
affordance stays for the icon-only list/bubble surfaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Node details: the "Signed node" cell now sits in the verification row
(left) alongside the manual-verify "Supported" cell (right), instead of a
separate full-width row. Tapping it opens the plain-language explanation
(SignedNodeDialog, extracted from NodeSignedStatusIcon and shared).

- InfoItem gains an onClick (the supporting-text slot is removed).
- SignedNodeDialog extracted in core/ui for reuse by icon + row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Broadens the old MQTT-only cell into a general "Transport" row showing
how the node was last heard, driven by Node.lastTransport
(MeshPacket.TransportMechanism). Hidden for internally-generated packets.

The verification row is now a clean signed | supported pair. Transport
icon+label mapping is centralised in a shared `transportInfo` helper so
TransportIcon (list badge) and the node-details row stay in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jamesarich jamesarich changed the title feat(ui): StatusSurface — keep status colors AA-legible (message bubble + node items) feat(ui): StatusSurface AA legibility + node-details signing/transport polish Jun 28, 2026
@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
3307 1 3306 0
View the top 1 failed test(s) by shortest run time
org.meshtastic.feature.messaging.component.MessageItemTest::mqttIconIsDisplayedWhenViaMqttIsTrue()[jvm]
Stack Traces | 0.366s run time
java.lang.AssertionError: Failed to perform isDisplayed check.
Reason: Expected exactly '1' node but could not find any node that satisfies: (ContentDescription = 'via MQTT' (ignoreCase: false))

	at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrThrow(SemanticsNodeInteraction.kt:178)
	at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrThrow$default(SemanticsNodeInteraction.kt:150)
	at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNode(SemanticsNodeInteraction.kt:84)
	at androidx.compose.ui.test.Assertions_skikoMainKt.checkIsDisplayed(Assertions.skikoMain.kt:32)
	at androidx.compose.ui.test.AssertionsKt.isDisplayed(Assertions.kt:351)
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:33)
	at org.meshtastic.feature.messaging.component.MessageItemTest$mqttIconIsDisplayedWhenViaMqttIsTrue$1.invokeSuspend(MessageItemTest.kt:68)
	at org.meshtastic.feature.messaging.component.MessageItemTest$mqttIconIsDisplayedWhenViaMqttIsTrue$1.invoke(MessageItemTest.kt)
	at org.meshtastic.feature.messaging.component.MessageItemTest$mqttIconIsDisplayedWhenViaMqttIsTrue$1.invoke(MessageItemTest.kt)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt$runComposeUiTest$1.invokeSuspend(ComposeUiTest.skiko.kt:89)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt$runComposeUiTest$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt$runComposeUiTest$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1$1$1$1$1.invokeSuspend(ComposeUiTest.skiko.kt:274)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1$1$1$1$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1$1$1$1$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.scene.BaseComposeScene$withMonotonicFrameClock$2.invokeSuspend(BaseComposeScene.skiko.kt:293)
	at androidx.compose.ui.scene.BaseComposeScene$withMonotonicFrameClock$2.invoke(BaseComposeScene.skiko.kt)
	at androidx.compose.ui.scene.BaseComposeScene$withMonotonicFrameClock$2.invoke(BaseComposeScene.skiko.kt)
	at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatched(Undispatched.kt:66)
	at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43)
	at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:497)
	at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
	at androidx.compose.ui.scene.BaseComposeScene.withMonotonicFrameClock$suspendImpl(BaseComposeScene.skiko.kt:292)
	at androidx.compose.ui.scene.BaseComposeScene.withMonotonicFrameClock(BaseComposeScene.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invokeSuspend(ComposeUiTest.skiko.kt:273)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invoke(ComposeUiTest.skiko.kt)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$1.invokeSuspend(TestBuilders.kt:317)
	at _COROUTINE._BOUNDARY._(CoroutineDebugging.kt:42)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invokeSuspend(ComposeUiTest.skiko.kt:273)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$1.invokeSuspend(TestBuilders.kt:317)
Caused by: java.lang.AssertionError: Failed to perform isDisplayed check.
Reason: Expected exactly '1' node but could not find any node that satisfies: (ContentDescription = 'via MQTT' (ignoreCase: false))

	at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrThrow(SemanticsNodeInteraction.kt:178)
	at androidx.compose.ui.test.SemanticsNodeInteraction.fetchOneOrThrow$default(SemanticsNodeInteraction.kt:150)
	at androidx.compose.ui.test.SemanticsNodeInteraction.fetchSemanticsNode(SemanticsNodeInteraction.kt:84)
	at androidx.compose.ui.test.Assertions_skikoMainKt.checkIsDisplayed(Assertions.skikoMain.kt:32)
	at androidx.compose.ui.test.AssertionsKt.isDisplayed(Assertions.kt:351)
	at androidx.compose.ui.test.AssertionsKt.assertIsDisplayed(Assertions.kt:33)
	at org.meshtastic.feature.messaging.component.MessageItemTest$mqttIconIsDisplayedWhenViaMqttIsTrue$1.invokeSuspend(MessageItemTest.kt:68)
	at org.meshtastic.feature.messaging.component.MessageItemTest$mqttIconIsDisplayedWhenViaMqttIsTrue$1.invoke(MessageItemTest.kt)
	at org.meshtastic.feature.messaging.component.MessageItemTest$mqttIconIsDisplayedWhenViaMqttIsTrue$1.invoke(MessageItemTest.kt)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt$runComposeUiTest$1.invokeSuspend(ComposeUiTest.skiko.kt:89)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt$runComposeUiTest$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt$runComposeUiTest$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1$1$1$1$1.invokeSuspend(ComposeUiTest.skiko.kt:274)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1$1$1$1$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1$1$1$1$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.scene.BaseComposeScene$withMonotonicFrameClock$2.invokeSuspend(BaseComposeScene.skiko.kt:293)
	at androidx.compose.ui.scene.BaseComposeScene$withMonotonicFrameClock$2.invoke(BaseComposeScene.skiko.kt)
	at androidx.compose.ui.scene.BaseComposeScene$withMonotonicFrameClock$2.invoke(BaseComposeScene.skiko.kt)
	at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatched(Undispatched.kt:66)
	at kotlinx.coroutines.intrinsics.UndispatchedKt.startUndispatchedOrReturn(Undispatched.kt:43)
	at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:497)
	at kotlinx.coroutines.BuildersKt.withContext(Unknown Source)
	at androidx.compose.ui.scene.BaseComposeScene.withMonotonicFrameClock$suspendImpl(BaseComposeScene.skiko.kt:292)
	at androidx.compose.ui.scene.BaseComposeScene.withMonotonicFrameClock(BaseComposeScene.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invokeSuspend(ComposeUiTest.skiko.kt:273)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invoke(ComposeUiTest.skiko.kt)
	at androidx.compose.ui.test.SkikoComposeUiTest$runTest$1.invoke(ComposeUiTest.skiko.kt)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$1.invokeSuspend(TestBuilders.kt:317)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
	at kotlinx.coroutines.test.TestDispatcher.processEvent$kotlinx_coroutines_test(TestDispatcher.kt:24)
	at kotlinx.coroutines.test.TestCoroutineScheduler.tryRunNextTaskUnless$kotlinx_coroutines_test(TestCoroutineScheduler.kt:98)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTest$2$1$workRunner$1.invokeSuspend(TestBuilders.kt:326)
	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:34)
	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
	at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:256)
	at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:54)
	at kotlinx.coroutines.BuildersKt__BuildersKt.runBlockingImpl(Builders.kt:30)
	at kotlinx.coroutines.BuildersKt.runBlockingImpl(Unknown Source)
	at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK(Builders.concurrent.kt:172)
	at kotlinx.coroutines.BuildersKt.runBlockingK(Unknown Source)
	at kotlinx.coroutines.BuildersKt__Builders_concurrentKt.runBlockingK$default(Builders.concurrent.kt:157)
	at kotlinx.coroutines.BuildersKt.runBlockingK$default(Unknown Source)
	at kotlinx.coroutines.test.TestBuildersJvmKt.createTestResult(TestBuildersJvm.kt:10)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:309)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
	at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:167)
	at kotlinx.coroutines.test.TestBuildersKt.runTest-8Mi8wO0(TestBuilders.kt:1)
	at androidx.compose.ui.test.SkikoComposeUiTest.runTest(ComposeUiTest.skiko.kt:261)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt.runSkikoComposeUiTest-uV_hrag(ComposeUiTest.skiko.kt:137)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt.runSkikoComposeUiTest-uV_hrag$default(ComposeUiTest.skiko.kt:119)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt.runComposeUiTest-exY8QGI(ComposeUiTest.skiko.kt:84)
	at androidx.compose.ui.test.v2.ComposeUiTest_skikoKt.runComposeUiTest-exY8QGI$default(ComposeUiTest.skiko.kt:78)
	at org.meshtastic.feature.messaging.component.MessageItemTest.mqttIconIsDisplayedWhenViaMqttIsTrue(MessageItemTest.kt:33)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

TransportIcon now uses the centralised transportInfo short labels, so the
MQTT badge's content description changed from "via MQTT" to "MQTT".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jamesarich jamesarich added the skip-docs-check Dismiss the advisory docs-staleness check (no user/developer doc update needed) label Jun 28, 2026
@jamesarich jamesarich added this pull request to the merge queue Jun 28, 2026
Merged via the queue into main with commit f03cf1f Jun 28, 2026
26 checks passed
@jamesarich jamesarich deleted the feat/status-surface branch June 28, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request skip-docs-check Dismiss the advisory docs-staleness check (no user/developer doc update needed)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant