Post-PIN, no-servers FTUE landed users on a dead-end avatar speech bubble (“I’m not seeing any local Krill Servers…”). Nothing actionable behind it; the only recovery was knowing to tap the avatar and pick KrillApp.Server from the menu, which the speech-bubble copy didn’t surface. Phase 1 (#256) had hidden the childless KrillApp.Client from the swarm graph and reserved MenuCommand.KeepBuildingSwarm for a follow-up wire that this phase delivers.
The empty-state UI was a single screenCore.announceDialog(text) call with no affordance behind the text. Locally-runnable walkthroughs (Project / Journal / TaskList / DataPoint) hadn’t been authored yet, and the FTUE pipeline assumed “the user will find a server” was the only happy path. There was no engine-shaped place to plug a “what would you like to do?” chooser into, so the FTUE state machine deadended whenever beacon discovery completed without finding anything.
Introduced a JSON-driven walkthrough engine in shared/.../flow/:
KrillFlow + FlowStep data classes (six MVP variants from the spec plus three Phase-2 terminal effects: OpenExternalUrl, OpenManualServerConnect, Done). JSON discriminator is type.FlowLoader mirrors FeatureLoader (classpath / NSBundle / ktor fetch, per-platform). The iOS actual now handles subdirectory paths (flows/foo.json) by splitting on / and using NSBundle.pathForResource(name:ofType:inDirectory:) — the existing flat-name path didn’t extend.FlowRegistry is a Koin singleton wrapping a StateFlow<Map<String, KrillFlow>>. One-shot loadAll() populates it from bundledFlowIds.FlowEngine is a pure-Kotlin state holder. The non-obvious bit: autoResolveLogicalSteps walks past RequireServerOrBranch, CreateNode, OpenEditor, and ChainTo whenever the cursor moves, so the UI only ever lands on user-facing steps (Intro, Input, OpenExternalUrl, OpenManualServerConnect, Done) or Finished. Without this, a user clicking “Continue” on Input would land on a dead CreateNode step that has no rendering.Composables under composeApp/.../app/walkthrough/:
WalkthroughController — Koin singleton holding open: StateFlow<Boolean>; the single point any caller flips to open the overlay.WalkthroughHost — top-level overlay; loads the registry, wires a FlowEngineHost (closure-per-side-effect), renders the chooser or the active FlowSheet, and shows the persistent “Servers online: N” header which on tap exits and triggers screenCore.executeCommand(KrillApp.Server).FlowChooser — card grid filtered by Platform and requiresServer; comingSoon flows render greyed-out.FlowSheet — renders the engine’s current user-facing step.Wire-up:
ScreenCore constructor now takes WalkthroughController; MenuCommand.KeepBuildingSwarm calls controller.openChooser() instead of announceDialog("Walkthroughs coming soon"). ScreenCoreTest updated to lock the new behaviour while still pinning the no-side-effects-on-Client-node contract from Phase 1.ClientScreen.kt mounts WalkthroughHost near the root. The legacy “I’m not seeing any local Krill Servers…” announceDialog is replaced by an auto-open of the chooser when !ftue && !needsPin && discoveryDone && !hasServers && !clientHasChildren. A rememberSaveable guard keeps it one-shot per session — dismissal is sticky.connect-to-server.json (intro → OpenManualServerConnect), swarm-setup-guide.json (intro → OpenExternalUrl), plus three Phase 3-5 stubs marked comingSoon: true.Ride-along: RequiresServerFlagTest was unblocked. Commit 622fbe7e0 (“ftue spec”) flipped KrillApp.DataPoint.json to requiresServer=false ahead of its Phase 5 task without bumping the test ratchet. Added KrillApp.DataPoint.json to expectedServerFreeFilenames, bumped counts (7→8 free, 30→29 required), and added a comment pointing at the Phase 5 task that owns the flip.
Three rules, each pointing at a place a future regression would slip in:
Input → CreateNode → OpenEditor) must not require an extra “Continue” tap on the side-effect step. New FlowStep variants that are pure side effects should extend autoResolveLogicalSteps so the renderer never has to special-case them.FlowStepTest parses each variant from a hardcoded JSON string so renaming a discriminator or @SerialName surfaces as a test failure, not a silent runtime parse swallowed by flowJson.ignoreUnknownKeys. Add the matching test alongside any new FlowStep subclass.requiresServer flip without a test ratchet bump breaks PR-Verify CI for everyone. RequiresServerFlagTest is intentionally a hard ratchet (one of #241’s locks). When relaxing a node type’s requiresServer flag (Phases 3-5 of local-first-onboarding/tasks.md will each do exactly this), update expectedServerFreeFilenames and the partition counts in the same commit — never split JSON flip and test bump across PRs.