Symptom

A fresh-install user past TOS + PIN + beacon discovery was already shielded from a stranded KrillApp.Client floating in the swarm graph — but only because Phase 0 of local-first-onboarding (krill#255) hadn’t yet wired the Client into the layout at all. Phase 1’s spec scenarios require the Client to actually appear in the graph the moment it gains its first direct child (“Client appears when first Project is created”), so the layout needs both halves of the rule: include Client when it has children, exclude when it doesn’t. And the new MenuCommand.KeepBuildingSwarm entry that landed in NodeChildren.load(KrillApp.Client) had no executor wired — selecting it fell through ScreenCore.executeCommand’s “menu option” branch into the generic editing path, which would have transitioned the Client node into EDITING state and surfaced nothing visible to the user.

Root cause

Two seams were missing:

A third seam surfaced once the in-graph Client became reachable: the avatar menu lookup at ClientScreen.kt keyed off node.id == installId() to pick between the wide sealed-root menu and the focused per-node menu. With the Client now potentially in the graph, both the corner avatar tap and the graph-Client tap end up with selectedNodeId == installId(), so that discriminator no longer says what the spec requires it to say.

Fix

Three coordinated changes in this PR:

  1. composeApp/.../NodeLayout.kt factors the root-node selection out into an internal helper selectLayoutRoots(nodes: List<Node>) and uses it from computeNodePositions. The helper returns Servers always plus the Client iff the Client has at least one direct child. The renamed local rootNodes flows through the existing first/second/third-pass layout machinery unchanged. selectLayoutRoots is a pure function on List<Node>, which is what the unit tests pin.

  2. composeApp/.../ScreenCore.kt gains a selectedViaAvatar: StateFlow<Boolean> and a fun selectAvatar(avatarId: String) entry point. The avatar’s onSelect calls selectAvatar(installId()) instead of selectNode(installId()), which sets the flag to true; every selectNode(...) call (graph tap, deselect, peer dialog) and every reset() clears it. ClientScreen keys its menu lookup off the flag rather than the id, so the wide sealed-root menu only appears when the user actually tapped the avatar — tapping the in-graph Client falls through to the focused Project + KeepBuildingSwarm menu the spec requires. The avatarId is supplied by the caller so the entry point can be unit-tested without touching the platform install-id file.

  3. ScreenCore.executeCommand short-circuits MenuCommand.KeepBuildingSwarm before the generic editing branch: it calls announceDialog("Walkthroughs coming soon") and clears the selection. Phase 2 of local-first-onboarding replaces this with the FlowChooser walkthrough overlay; the placeholder exists so the menu item is at least reachable and self-explanatory in the meantime.

Tests

Prevention