In-App Guidance — Tours & Info Popovers
Standardized system for showing onboarding tours, per-feature walkthroughs, and single-element info popovers. One API, one overlay, one persistence layer — mounted once in shared code, active on Desktop / Android / iOS / wasmJs.
Overview
The guidance system replaces the ad-hoc walkthrough that previously lived in composeApp/.../desktopMain/.../ui/walkthrough/. Two presentations share one step model and one anchor registry:
- Tour — full-screen scrim with a rectangular spotlight cutout around the target element, plus a bottom card with
Skip / Back / Next / Finish. Used for multi-step product tours like the 7-step main-navfirst_runflow. - Info — a small
Popupanchored next to a single element, no scrim, dismissable via outside-click, Esc, or its close affordance. Used for “what’s this?” hints (OterInfoIcon).
Each Guidance is identified by a string id (first_run, finance_intro, tasks.priority_info, …) and tracked independently in GuidancePreferences. triggerOnce = true (default) guarantees the user sees it at most once; triggerOnce = false lets the same popover be re-opened on demand.
Architecture
The catalog is data; the ViewModel is glue; the overlay is presentation. To add a new tour you only touch the catalog and (optionally) the strings file.
How it works — deep dive
This section unpacks what actually happens on each interaction, in the order the composition sees them.
1. Mounting — one seam, every platform
The whole system is wired once, inside SharedAppNavHost (commonMain). Every platform’s app entry — desktop App.kt, Android MainActivity.kt, iOS IOSApp.kt, wasmJs App.kt — calls into this shared nav host, so mounting happens automatically:
// shared/.../navigation/AppNavHost.kt
@Composable
fun SharedAppNavHost(…) {
val guidanceViewModel: GuidanceViewModel = koinInject()
val guidanceState by guidanceViewModel.state.collectAsState()
val guidanceAnchors = remember { GuidanceAnchors() }
CompositionLocalProvider(
LocalGuidanceAnchors provides guidanceAnchors,
LocalGuidanceController provides guidanceViewModel,
) {
Box(Modifier.fillMaxSize()) {
if (isAuthRoute) navContent(Modifier) else shell(navController, navContent)
GuidanceOverlay(
state = guidanceState,
onNext = guidanceViewModel::next,
…
)
}
}
}Two lessons this shape captures:
- The overlay lives above the nav content, so its
Boxorder matters — content first, overlay last, so the scrim and spotlight card sit on top. - The anchor registry is a
remember { … }at the nav host, so it survives every nav transition but rebuilds on config changes (locale swap useskey(appLanguageTag)around the theme, which nukes and recomposes the tree — the anchor map goes with it, no stale rects).
Koin bindings for the VM + preferences are provided per-platform (desktop Modules.kt, AndroidAppModule.kt, IOSModules.kt, WebModules.kt). All use single { } so koinInject() in commonMain resolves the same instance regardless of platform.
2. Anchor lifecycle — the write path
When a feature screen composes, its call to Modifier.guidanceAnchor("tasks.filters") (shared/.../guidance/Modifier.guidanceAnchor.kt) does two things:
fun Modifier.guidanceAnchor(id: String): Modifier = composed {
val anchors = LocalGuidanceAnchors.current
DisposableEffect(id, anchors) {
onDispose { anchors.unregister(id) }
}
onGloballyPositioned { coords ->
anchors.register(id, coords.boundsInRoot())
}
}onGloballyPositionedfires on every layout pass with the composable’s window-relativeRect.GuidanceAnchors.registeris diff-only — if the new rect equals the stored one, no write, no state notification, no recomposition. This is why scrolling a list doesn’t churn the overlay.DisposableEffect(id, anchors)runs itsonDisposewhen the composable leaves the composition (screen pop, view-mode switch, tab flip). The rect is removed from the map so the next tour step that targets that anchor gets a cleannullfallback instead of a stale ghost rect.
Because GuidanceAnchors is backed by a SnapshotStateMap, writes are observed by Compose — GuidanceOverlay recomposes automatically when the current step’s rect arrives.
3. Trigger flow — startIfUnseen and its guard
startIfUnseen(id) is the auto-trigger every feature screen uses:
// shared/.../ui/viewmodels/GuidanceViewModel.kt
override fun startIfUnseen(guidanceId: String) {
viewModelScope.launch {
if (_state.value.active != null) return@launch // guard (2026-07)
if (preferences.hasSeen(guidanceId)) return@launch
val guidance = GuidanceCatalog.find(guidanceId) ?: return@launch
startGuidance(guidance)
}
}Three checks, in order:
- Active-guidance guard — if any guidance is already running, silently no-op. Without this, a feature screen’s
LaunchedEffect(Unit) { startIfUnseen("tasks_intro") }would fire whilefirst_runwas still on step 4 and hijack it.start(id)(manual re-launch) has no such guard on purpose — the user explicitly asked for it. - Seen check —
GuidancePreferences.hasSeenreads the DataStore/localStorage set. This is also where the legacywalkthrough_completedmigration runs on first call. - Catalog lookup — unknown ids silently no-op so renaming a guidance mid-rollout doesn’t crash callers.
start(id) (manual) skips the first two checks and always starts. Both paths call startGuidance(guidance) which resets _state.value = GuidanceState(active = guidance, currentStepIndex = 0).
4. Overlay rendering — spotlight math + fallbacks
GuidanceOverlay (shared/.../ui/guidance/GuidanceOverlay.kt) reads:
state.active→ the runningGuidance(ornull— render nothing).state.currentStep→ the step to display.LocalGuidanceAnchors.current.boundsFor(step.targetAnchorId)→ the targetRect, ornull.
For Tour mode:
- Draws a full-screen scrim in a
Canvas. - If
anchorBounds != null, punches a rounded rectangle “hole” in the scrim by drawing the bounds rect withBlendMode.Clear. This is the spotlight cutout — the anchored element shows through untouched. - Draws the step card (
Skip / Back / Next / Finish) at the bottom, offset above the spotlight if it would collide. - If
anchorBounds == null(anchor not mounted, or hasn’t laid out yet), renders scrim-only. No flicker, no jitter — the tour is honest about not knowing where to point.
For Info mode:
- Renders a Compose
Popuppositioned via aPopupPositionProviderthat honors the step’sPlacement(BottomCenter / Above / Below / Left / Right of anchor). - Fallback: if
anchorBounds == null, top-centered “toast” position.
5. Auto-trigger placement — where to put LaunchedEffect
Every feature intro follows the same shape at the top of its VM-wrapper composable:
@Composable
fun MyFeatureScreen(viewModel: MyFeatureViewModel, …) {
val guidance = LocalGuidanceController.current
LaunchedEffect(Unit) {
viewModel.loadData()
guidance.startIfUnseen(GuidanceIds.MY_FEATURE_INTRO)
}
…
}Rules of thumb:
- Use
LaunchedEffect(Unit), notLaunchedEffect(someKey). We want first-visit behavior, not “trigger every time X changes.” - Grab the controller via
LocalGuidanceController.current, notkoinInject<GuidanceViewModel>(). The local resolves to aNoOpGuidanceControllerin previews/tests where no ViewModel is in scope, so composables stay safe. - Place the call at the outer VM-wrapper, not deep inside a lazy-list item. Deeply-nested
LaunchedEffects trigger every scroll and get cancelled on scroll-out.
6. Anchor id conventions
- Route-level ids — one per feature landing screen, matching the nav route string:
"dashboard","tasks","habits", etc. These are also the step 1 anchor of each feature intro tour. - Sub-anchors —
<feature>.<element>:"tasks.filters","habits.add_button","finance.budgets_tab". Keeps the flat namespace scannable and matches the file-org intuition of “which feature owns this.” - Never reuse an id across feature screens. Anchor registry is a global
Map<String, Rect>— if two screens both register"list", whichever mounts last wins and the tour points at the wrong place.
7. Cross-platform gotchas
- Android rotation / config change — the
remember { GuidanceAnchors() }is scoped to the composition. Compose retains state across config changes automatically, so anchor identities are preserved but the individual rects re-emit fromonGloballyPositionedafter re-layout. The active guidance survives viaGuidanceViewModel(asinglein Koin, not tied to activity lifetime). - iOS
Popupsafe area — Info popovers usePopupPositionProviderthat clamps positions inside the window. If a popover appears clipped against the notch, verify the anchor’s bounds include padding above the safe area. - wasmJs
localStorage— synchronous underneath, but theexpect classAPI issuspendfor symmetry. Migrations on wasm are no-ops (no legacy key existed there). - Desktop dev preview key — deleting
~/.config/OterDesktop/file.preferences_pbresets every preference (theme, locale, seen-ids). To reset only guidance in the field, add aReset guidanceaffordance that callspreferences.markSeen(id)for the empty set — not yet built.
Authoring a New Tour
// 1. Add string keys to shared/commonMain/composeResources/values/strings.xml (and values-es/).
// <string name="guidance_my_tour_step1_title">…</string>
// <string name="guidance_my_tour_step1_body">…</string>
// 2. Register the guidance in GuidanceCatalog.kt.
private val myFeatureTour = Guidance(
id = "my_feature_intro",
mode = GuidanceMode.Tour,
steps = listOf(
GuidanceStep(
id = "step1",
titleRes = Res.string.guidance_my_tour_step1_title,
bodyRes = Res.string.guidance_my_tour_step1_body,
targetAnchorId = "my_feature.header",
),
// …
),
routeForAnchor = mapOf("my_feature.header" to "my_feature"),
)// 3. Anchor the target element in the screen (any depth).
Box(modifier = Modifier.guidanceAnchor("my_feature.header")) {
MyFeatureHeader(…)
}// 4. Trigger it. Either auto on first visit:
val guidance = koinInject<GuidanceViewModel>()
LaunchedEffect(Unit) { guidance.startIfUnseen("my_feature_intro") }
// or from a button:
TextButton(onClick = { guidance.start("my_feature_intro") }) { Text("Take the tour") }Authoring a New Info Popover
// 1. Add the strings (title + body).
// 2. Register in the catalog with mode = GuidanceMode.Info.
private val myInfo = Guidance(
id = "my_feature.spend_chart_info",
mode = GuidanceMode.Info,
steps = listOf(
GuidanceStep(
id = "info",
titleRes = Res.string.guidance_my_info_title,
bodyRes = Res.string.guidance_my_info_body,
targetAnchorId = "my_feature.spend_chart",
placement = Placement.BelowAnchor,
),
),
triggerOnce = false, // info popovers are usually re-openable
)
// 3. Drop the icon anywhere in the UI; it self-registers as its own anchor.
OterInfoIcon(
guidanceId = "my_feature.spend_chart_info",
anchorId = "my_feature.spend_chart",
)Anchor Lifecycle
Modifier.guidanceAnchor(id) publishes the composable’s boundsInRoot() rect into GuidanceAnchors (a SnapshotStateMap<String, Rect>) and registers a DisposableEffect that removes the entry when the composable leaves the composition. Two guarantees:
- No stale rects after a screen pops or a locale change recomposes the tree.
- No write storms: the registry rejects writes that don’t differ from the stored rect, so anchor updates don’t fire on every scroll frame.
The overlay reads anchors.boundsFor(currentStep.targetAnchorId). If the rect is null (anchor not yet measured, or off-screen), Tour renders a scrim-only overlay (no flicker), and Info falls back to a centered top-of-window position.
Auto-Navigation
When a tour step’s targetAnchorId belongs to a screen the user isn’t currently on, AppNavHost’s LaunchedEffect(guidanceState.active?.id, currentStepIndex) looks the id up in active.routeForAnchor and calls navController.navigate(route) { launchSingleTop = true }. The next recomposition resolves the rect from the now-mounted anchor and draws the spotlight.
Keeping the route map at the Guidance level (not on each step) lets the same anchor id move between routes without rewriting steps.
Persistence
GuidancePreferences mirrors the existing OnboardingPreferences pattern: an expect class with DataStore-backed actuals on JVM/Android/iOS and a localStorage-backed actual on wasmJs.
expect class GuidancePreferences {
val seenIds: Flow<Set<String>>
suspend fun markSeen(id: String)
suspend fun hasSeen(id: String): Boolean
}- Storage keys (JVM/Android/iOS):
guidance_seen_ids(stringSetPreferencesKey) andguidance_migrated_v1(booleanPreferencesKey). - Migration: on the first
hasSeen(…)call, if legacywalkthrough_completed = trueandguidance_migrated_v1is not yet set,"first_run"is added toseenIdsand the v1 flag is written. The legacy key is never written again — there is one source of truth after migration.
GuidanceViewModel.finish() calls markSeen(activeGuidanceId) only when the guidance has triggerOnce = true. Info popovers (triggerOnce = false) leave no trace.
Key Files
| File | Purpose |
|---|---|
shared/.../oter/guidance/Guidance.kt | Guidance, GuidanceStep, GuidanceMode, Placement |
shared/.../oter/guidance/GuidanceState.kt | Active tour/info session state (current step, helpers) |
shared/.../oter/guidance/GuidanceAnchors.kt | Anchor-id → Rect registry, diff-only writes |
shared/.../oter/guidance/Modifier.guidanceAnchor.kt | Modifier that registers/unregisters anchor bounds |
shared/.../oter/guidance/GuidanceController.kt | start / startIfUnseen / dismiss interface + LocalGuidanceController |
shared/.../oter/guidance/GuidanceCatalog.kt | In-code registry of named guidances (firstRunTour, etc.) |
shared/.../oter/ui/guidance/GuidanceOverlay.kt | Tour scrim + spotlight + card; Info Popup + position provider |
shared/.../oter/ui/guidance/OterInfoIcon.kt | Drop-in info-icon button (self-anchoring) |
shared/commonMain/.../preferences/GuidancePreferences.kt | expect class for seen-id persistence |
shared/nonJsMain/.../preferences/GuidancePreferences.kt | DataStore-backed actual + legacy migration |
shared/wasmJsMain/.../preferences/GuidancePreferences.wasmJs.kt | localStorage-backed actual |
shared/src/commonMain/kotlin/ui/viewmodels/GuidanceViewModel.kt | Implements GuidanceController, owns GuidanceState (moved to shared in 2026-07 as part of the mobile-parity work). |
shared/.../navigation/AppNavHost.kt | Provides LocalGuidanceAnchors, LocalGuidanceController, renders GuidanceOverlay; auto-starts first_run after auth. Every platform (Desktop/Android/iOS) now inherits this wiring for free. |
composeApp/desktopMain/.../di/Modules.kt | Koin singletons for GuidancePreferences + GuidanceViewModel |
shared/commonMain/composeResources/values/strings.xml | guidance_* action + content strings (also values-es/) |
Testing
- Unit (recommended):
GuidanceViewModel.start → next → next → … → finishends with the guidance id inGuidancePreferences.seenIds. Skip writes the id too. - Unit:
GuidanceViewModel.startIfUnseen("first_run")no-ops when the id is already inseenIds. - Unit:
GuidancePreferencesmigration — pre-seed legacywalkthrough_completed = true, observeseenIdsafter onehasSeencall, assert"first_run"is present andguidance_migrated_v1 = true. - Manual: delete
~/.config/OterDesktop/file.preferences_pb, run the desktop app, log in — the first-run tour should auto-start and walk Dashboard → Tasks → Habits → Finance → Timers → Chat. Re-launch — it does not re-trigger. - Manual: click the
OterInfoIcononTasksScreen— small popover appears below the icon, dismisses on outside-click, Esc, and the close button. - Manual (locale change mid-tour): open
first_run, switch language in Settings —key(appLanguageTag)rebuilds the tree, the anchor registry is rebuilt, no stale spotlight rect remains.
Troubleshooting
Tour appears but with no spotlight (scrim-only)
- The anchor for that step isn’t mounted. Check the screen owning
targetAnchorIdis mapped inrouteForAnchorso auto-nav can switch to it. - Confirm the anchor element actually uses
Modifier.guidanceAnchor(id)with the same id string.
Info popover renders in the top-center instead of next to the element
anchorBoundsisnull— the anchor either isn’t mounted or hasn’t laid out yet on the first frame. Triggering from a click on anOterInfoIconavoids this (the icon itself is the anchor); for external triggers, ensure the anchor was visible beforestart(id)is called.
first_run keeps appearing on every launch
GuidancePreferences.markSeenwas not called — usually becausedismiss()was invoked directly instead of going throughfinish()(onlyfinish()persists). VerifySkip/Finishbutton paths callskip()/next()from the last step.- For a fresh slate, delete the DataStore file:
rm ~/.config/OterDesktop/file.preferences_pb.
Legacy migration didn’t apply
GuidancePreferences.runMigrationOnceruns insidehasSeen(...). If your code only ever callsstart(id)(which bypasseshasSeen), the migration is deferred until the firststartIfUnseenorhasSeencall.AppNavHost.ktcallsstartIfUnseen(FIRST_RUN)on auth, which is the trigger.
Related Docs
- Onboarding — separate from guidance; gates the first-launch wizard via
OnboardingPreferences, not the guidance seen-set. - Notifications — guidance is local-only; there is no server-driven “show this tour to user X” yet.
Registered guidances (Slice A, 2026-07)
Alongside first_run, finance_intro, and tasks.priority_info, the Plan-cluster feature intros were added — a 3-step Tour per feature, auto-triggered via LaunchedEffect { startIfUnseen(...) } at the top of each screen’s VM-wrapper composable. Step 1 spotlights the whole screen (overview); steps 2–3 spotlight distinct sub-components / sub-features:
| Id | Feature screen | Step anchors (in order) |
|---|---|---|
dashboard_intro | DashboardScreen.kt | dashboard → dashboard.up_next → dashboard.feature_cards |
tasks_intro | TasksScreen.kt | tasks → tasks.filters → tasks.list |
habits_intro | HabitsScreen.kt | habits → habits.sections → habits.add_button |
projects_intro | ProjectsScreen.kt | projects → projects.hero → projects.add_button |
timers_intro | TimersScreen.kt | timers → timers.segments → timers.add_button |
startIfUnseen gained a guard that skips when another guidance is already active — this keeps first_run from being hijacked by a per-feature auto-trigger and vice-versa. start(id) (manual re-launch) is unaffected.
Slices B–D (Life / Money / Hub) follow the same shape: add anchor id + strings + catalog entry, drop a LaunchedEffect { startIfUnseen(id) } at the top of the feature screen, and wrap the screen root in Modifier.guidanceAnchor(id).
Slice A file map
Concrete pointers to the code paths that landed for the Plan-cluster tours:
shared/.../guidance/GuidanceCatalog.kt— five<feature>IntroTourvalues +GuidanceIds.*_INTROconstants + 10 newGuidanceAnchorIds.*sub-anchor constants.shared/.../ui/screens/DashboardScreen.kt— outerBox(Modifier.fillMaxSize().guidanceAnchor(DASHBOARD))at the VM-wrapper; sub-anchors on theUpNextCarditem (DASHBOARD_UP_NEXT) and the first bento row (DASHBOARD_FEATURE_CARDS, usingitemsIndexedto pick row 0).shared/.../ui/screens/TasksScreen.kt— the shared inner screen takes aModifier; the VM-wrapper passesModifier.fillMaxSize().guidanceAnchor(TASKS). Sub-anchors chained ontoRangeChipsRow’s modifier (TASKS_FILTERS) and the mainLazyVerticalGrid(TASKS_LIST).shared/.../ui/screens/HabitsScreen.kt— same modifier-threading pattern forHABITS; sub-anchors on the sectionsLazyColumn(HABITS_SECTIONS) and the FABBox(HABITS_ADD_BUTTON).shared/.../ui/screens/ProjectsScreen.kt— outerBoxwrap (PROJECTS);Boxwraps aroundMobileHero(PROJECTS_HERO) and the FAB insidefloatingActionButton(PROJECTS_ADD_BUTTON).shared/.../ui/screens/TimersScreen.kt— outerBoxwrap (TIMERS);TimersSegmentedControlmodifier chained (TIMERS_SEGMENTS); FABBox(TIMERS_ADD_BUTTON).shared/src/commonMain/composeResources/values/strings.xml+values-es/strings.xml— threeguidance_<feature>_intro_*string pairs per feature.shared/src/commonTest/kotlin/.../GuidanceCatalogTest.kt— asserts every intro is a Tour, has 3 steps, and every anchor has a matchingrouteForAnchorentry.