feat(client/android): the host card wears its OS mark where the initial was
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 20s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m7s
android / android (push) Canceled after 2m56s
ci / rust (push) Canceled after 2m37s
ci / rust-arm64 (push) Canceled after 2m25s
ci / web (push) Canceled after 2m11s
ci / docs-site (push) Canceled after 2m11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s

The OS mark rode along at 12 dp in front of the address, competing with the
text it prefixed. The avatar circle above it was showing the host's first
letter — which says nothing the name underneath doesn't already say, twice
over on a row of home-worker-N boxes.

Put the mark in the circle instead, at 24 dp in the avatar's own
onPrimaryContainer tint, and drop it from the address line. A host that
advertises no OS chain — or one we ship no mark for — keeps the initial, so
those cards look exactly as they did and a mixed row still reads as one set.

On glass: the two Arch hosts wear the Arch mark; steamdeck and the Windows
runner, both predating the `os=` TXT, keep their letters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:32:22 +02:00
co-authored by Claude Opus 5
parent be2fabcfba
commit 9ed967cbaf
@@ -69,9 +69,9 @@ data class HostMenuItem(
)
/**
* A host as an Apple-style card: a colored letter-avatar, name + address, a trust pill, and (for
* saved hosts) an overflow menu with Wake / Edit / Forget plus whatever [menuItems] adds. Tapping
* the card connects.
* A host as an Apple-style card: a colored avatar carrying the host's OS mark (its initial when we
* don't know the OS), name + address, a trust pill, and (for saved hosts) an overflow menu with
* Wake / Edit / Forget plus whatever [menuItems] adds. Tapping the card connects.
*
* [profileLabel] names the settings profile this card connects with. On a host's own card that is
* its default binding, drawn as a quiet chip — the card says what a tap will do. On a **pinned
@@ -84,7 +84,7 @@ fun HostCard(
address: String,
status: HostStatus,
online: Boolean = false,
/** OS-identity chain (mDNS `os` TXT / stored), for the address line's OS mark. "" = none. */
/** OS-identity chain (mDNS `os` TXT / stored), drawn as the avatar's mark. "" = the initial. */
os: String = "",
enabled: Boolean,
onConnect: () -> Unit,
@@ -129,7 +129,7 @@ fun HostCard(
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
HostAvatar(name, online)
HostAvatar(name, online, os)
Spacer(Modifier.height(10.dp))
Text(
name,
@@ -138,28 +138,14 @@ fun HostCard(
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Row(verticalAlignment = Alignment.CenterVertically) {
// The OS mark leads the address line; absent entirely for a host that
// doesn't advertise one, so those cards render exactly as they always did.
val osIcon = resolveOsIcon(os)
if (osIcon != null) {
Icon(
osIcon,
contentDescription = os,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
}
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
}
Text(
address,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
if (profileLabel != null || reserveProfileSlot) {
Spacer(Modifier.height(10.dp))
Box(
@@ -283,8 +269,9 @@ private val PROFILE_CHIP_SLOT = 26.dp
private val PRESENCE_ONLINE = Color(0xFF4ADE80)
/**
* The host's letter avatar (Apple-contact style) with its presence as a dot on the corner — the
* idiom every contact list already uses, and one fewer labelled badge on a small card.
* The host's avatar (Apple-contact style) with its presence as a dot on the corner — the idiom
* every contact list already uses, and one fewer labelled badge on a small card. It carries the
* host's OS mark when [os] resolves to one we ship, and the host's initial otherwise.
*
* [online] is true when the host advertises on mDNS OR answers the reachability probe, so a
* routed/VPN host that never advertises still reads as up. Online is a FILLED green dot, offline a
@@ -292,9 +279,10 @@ private val PRESENCE_ONLINE = Color(0xFF4ADE80)
* colour-blind reader and a screenshot in greyscale. TalkBack gets the word either way.
*/
@Composable
fun HostAvatar(name: String, online: Boolean = false) {
fun HostAvatar(name: String, online: Boolean = false, os: String = "") {
val letter = name.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val cardColor = CardDefaults.elevatedCardColors().containerColor
val osIcon = resolveOsIcon(os)
Box {
Box(
modifier = Modifier
@@ -303,11 +291,23 @@ fun HostAvatar(name: String, online: Boolean = false) {
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
// The OS mark IS the avatar when we know the OS — it identifies the machine better than
// the initial ever did, and it's the same circle, so a card whose host advertises no OS
// (or one we ship no mark for) keeps the letter and the row still reads as one set.
if (osIcon != null) {
Icon(
osIcon,
contentDescription = os,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
} else {
Text(
letter,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Box(
modifier = Modifier