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.
Two seams were missing:
composeApp/.../NodeLayout.kt only recognised KrillApp.Server as a
layout root. The Client follows the same self-rooted shape (parent == id)
and would never gain a position no matter what was hung under it.ScreenCore.executeCommand dispatches by type.isMenuOption() and then
only special-cases MenuCommand.Delete; everything else flows through a
shared editing path that calls nodeManager.editing(selectedNode) and
sets _selectedCommand = type. Letting KeepBuildingSwarm hit that path
would mark the Client EDITING and leak into KrillScreen’s command
dispatcher, which has no arm for the new command.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.
Three coordinated changes in this PR:
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.
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.
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.
composeApp/src/desktopTest/.../NodeLayoutFiltersTest.kt covers the
selection rule end-to-end against pure Node fixtures: empty swarm,
servers-only, childless Client filtered out, Client + direct child
becomes a root, self-reference doesn’t count, and grandchild-only doesn’t
qualify the Client.composeApp/src/desktopTest/.../ScreenCoreTest.kt locks the
KeepBuildingSwarm short-circuit (placeholder text set, selection
cleared, no editing/delete on the Client), and the
selectedViaAvatar transitions across selectAvatar / selectNode
/ selectNode(null) / reset. Tests use mockk-stubbed
ClientNodeManager per the existing NodeChildrenTest pattern.composeApp/build.gradle.kts desktopTest gate now excludes the
Roborazzi screenshot package when no screenshot mode is invoked
(instead of skipping the entire task), so plain ./gradlew
:composeApp:desktopTest actually runs the unit tests next to the
screenshot harness without churning PNG baselines. Roborazzi wrapper
tasks (recordRoborazziDesktop / verifyRoborazziDesktop /
compareRoborazziDesktop) re-include the screenshot scenarios.NodeLayout.computeNodePositions for is KrillApp.Server
filters that silently exclude it. Layout should pivot on “self-rooted &
has children” rather than on a concrete type whitelist.MenuCommand subtypes need an explicit branch in
ScreenCore.executeCommand. The shared editing fallback assumes the
command corresponds to a real, in-progress edit on the selected node —
any command whose semantic is “open a different UI surface” must
short-circuit before that fallback or it’ll mutate the selected node’s
state under the user.onlyIf pull in any
unit tests that happen to share the source set. Prefer filter {
excludeTestsMatching(...) } so the gate targets the slow / expensive
cohort directly and the rest of the source set keeps running on every
build.