Files
amethyst/.claude/skills/compose-expert/references/navigation.md
Vitor PamplonaandClaude Opus 5.5 395af4dc9c chore(skills): consolidate 33 skills into 14, verified against the tree
The 33 descriptions (~16k chars, ~19k with paths) overran Codex's skill-list
budget (2% of the context window, or 8,000 chars when unknown), and a review
found most bodies stale: paths from before the commons/commonsUI moves, the
deleted legacy desktop app, a callback signer API that no longer exists, and
example APIs that were never in the tree.

Kept, rewritten and checked against the code (every path, class and command):
account-state (+feeds), amy-expert, android-expert, auth-signers,
compose-expert (+API style, Navigation 3 back stacks), desktop-expert
(+run/package), event-store-semantics (+NIP-50 search), find-missing-
translations, kotlin-coroutines (+structured concurrency, flow modeling),
kotlin-multiplatform (+Gradle, composable extraction), ngit-pr,
nip85-trusted-assertions, nostr-expert (+NIP lookup, generated NIP catalog),
relay-client.

Dropped: textbook Compose/Kotlin technique skills, kotlin-expert,
kotlin-types-value-class, gradle-expert, quartz-integration (external-facing;
see the Quartz docs commit), find-non-lambda-logs (audit finished; the rule is
now in AGENTS.md Kotlin Style), and the /nip, /desktop-run, /extract commands.
The skill list is now ~4k chars. Also drops .claude/core-skills-plan.md
(stale history) and repoints SearchableEvent's KDoc to event-store-semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:21:01 -04:00

122 lines
7.5 KiB
Markdown

# Navigation 3 in the shared UI
Amethyst runs JetBrains' multiplatform Navigation 3 (`jetbrains-navigation3-ui` and
`jetbrains-lifecycle-viewmodel-navigation3` in `gradle/libs.versions.toml`). The app owns the back
stack as plain snapshot state and `NavDisplay` renders it. There is no `NavController`, no
`NavHost` and no navigation graph. Android and Desktop run the same code.
## Files
`commons/…` = `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/`,
`commonsUI/…` = `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/`.
| File | Role |
|------|------|
| `commons/…/model/navigation/Routes.kt` | `@Serializable sealed class Route`: every destination and its arguments |
| `commons/…/model/navigation/NavBackStacks.kt` | `NavStackEntry`, `NavBackStacks`: the back stack and its rules (headless) |
| `commonsUI/…/ui/navigation/navs/Nav.kt` | `Nav`, the shared `INav` over `NavBackStacks`, plus `LocalNavStackEntry` |
| `commonsUI/…/ui/navigation/navs/RememberNavs.kt` | `rememberNav()`: saves the back stack across config changes and process death |
| `commonsUI/…/ui/navigation/navs/ImeSettler.kt` | Waits for the keyboard to close before every navigation |
| `commonsUI/…/ui/navigation/host/NavDestinations.kt` | `NavDestinations` registry, the builders, `NavFamily`, the transitions, `canOpen` |
| `commonsUI/…/ui/navigation/host/SharedDestinations.kt` | `NavDestinations.sharedDestinations`: every shared screen |
| `commonsUI/src/jvmAndroid/…/ui/navigation/host/JvmDestinations.kt` | `jvmDestinations`: shared screens that need the JVM (git browser, cordn group chat); Android and Desktop both register them |
| `commonsUI/…/ui/navigation/host/NavigationHost.kt` | `NavigationHost`: the `NavDisplay` every front end shows |
| `commonsUI/…/ui/navigation/routes/RouteNavController.kt` | `isBaseRoute<T>(nav)`, `getRouteWithArguments`, `consumesSharesInPlace` |
| `commonsUI/…/ui/app/AmethystApp.kt` | Builds `NavDestinations` (shared + `AppRoot.registerDestinations`) for the main pane and each deck column |
| `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt` | Android: `androidDestinations` (Android-only screens), `NavigateIfIntentRequested` |
| `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/app/DesktopAppRoot.kt` | Desktop: `jvmDestinations` + `unavailable { UnavailableScreen(nav) }` |
## The back stack
`NavBackStacks` holds two pieces of state:
- `stack`: what is on screen, bottom to top. It is never empty and starts at `Route.Home`.
- `savedTabs`: the root entry of every bottom-bar tab the user left, keyed by its route.
Each `NavStackEntry` has a unique `id`. Its `contentKey` (`"nav-$id"`) keys the screen's
`rememberSaveable` state and its ViewModelStore, so the same route opened twice is two
independent screens. The rest is snapshot state, because it can change on an entry already on
screen:
- `route`: `newStack` onto the same destination hands the entry new arguments in place.
- `tabRoute`: the bar route this entry is the root of, if any (`tabRoot` is `tabRoute != null`).
A tab root has no back arrow, and tab switches fade. Saved tabs are filed under it, so a tab
re-argued by a deep link is still found by the bar.
- `drawerRoot`: opened from the navigation drawer. It can pop, but it keeps the bottom bar.
Operations (each one in `Nav` runs after `ImeSettler.settle()`):
| `INav` call | `NavBackStacks` | Effect |
|-------------|-----------------|--------|
| `nav(route)` | `push` | Pushes `route`, unless it is already on top |
| `navDrawer(route)` | `push(drawerRoot = true)` | Same push, marked as a drawer screen |
| `newStack(route)` | `newStack` | Drops the latest copy of `route` and everything above it, then shows it once. A same-class top takes the new arguments in place |
| `popUpTo(route, klass)` | `popUpTo` | Drops the latest `klass` entry and everything above it, then pushes `route` |
| `navBottomBar(route)` | `switchTab` | Drops pushes above the current tab root, keeps the left tab root in `savedTabs`, then restores `route`'s saved entry or opens it fresh |
| `popBack()` | `pop` | Pops the top. The last entry is never popped |
`Route.Home` stays at the bottom, so back from any tab returns to Home and back from Home leaves
the app. Nothing above a tab root is saved, so returning to a tab always lands on the tab itself.
## Rendering
`NavigationHost` passes `stack + savedTabs` to `rememberDecoratedNavEntries` with
`rememberSaveableStateHolderNavEntryDecorator()` and `rememberViewModelStoreNavEntryDecorator()`.
Saved tabs therefore keep their state while out of sight. Only `stack` goes to `NavDisplay`. When
an entry leaves both lists, its saved state and ViewModels are cleared.
Each entry provides `LocalNavStackEntry`. `INav.canPop()` and `showsBottomBar()` read it, so
during a predictive-back swipe a screen that is leaving keeps its own back arrow.
## Destinations
Register each screen once. Shared screens go in `sharedDestinations`; a platform adds its own
through `AppRoot.registerDestinations` (Android: `androidDestinations`). The builder decides the
motion (`NavFamily`) and whether the screen is capped to the reading column on wide panes
(`MaybeCappedScreen`):
| Builder | Motion | Cap |
|---------|--------|-----|
| `composableCapped<T> { }` / `composableCappedArgs<T> { route -> }` | fade | capped |
| `composable<T> { }` | fade | full width |
| `composableArgs<T>(capWidth) { route -> }` | fade | capped by default |
| `composableFromEnd<T>(capWidth) { }` / `composableFromEndArgs` | drill-in: slides from the end | capped by default |
| `composableFromBottom<T>(capWidth) { }` / `composableFromBottomArgs` | modal: rises from the bottom | capped by default |
`pushTransition` / `popTransition` in `NavDestinations.kt` read each entry's family and `tabRoot`
flag: a tab root fades, a drill-in slides in from the end and back out to it, a modal rises and
drops, and the screen behind steps back with a slight scale. The predictive-back gesture scrubs
the pop motion. On large screens (`NavTransitionTier.isLargeScreen`) the slides become short
shared-axis nudges.
A route the registry does not know fails the first time it is opened, unless the front end set
`unavailable { }` (the desktop does, since it lacks Health Connect, the QR camera, ...). Use
`canOpen(route)` to hide an entry point to a screen this front end may not have.
## Reading the current route
`nav.currentRoute` reads the top of the snapshot-backed stack. Read it directly, as the rail does
to pick the selected item. Where only a yes/no is needed, wrap it in `derivedStateOf` so the
caller recomposes only when the answer changes:
```kotlin
val onNotifications by remember(nav) { derivedStateOf { nav.currentRoute is Route.Notification } }
```
Outside composition (intent handlers), use `isBaseRoute<Route.X>(nav)` or
`getRouteWithArguments(Route.X::class, nav)`.
## Intents (Android)
`NavigateIfIntentRequested` in `AppNavigation.kt` (called from `AndroidAppRoot`) turns
`ACTION_VIEW`, `ACTION_SEND` / `SEND_MULTIPLE` and NFC intents into `newStack(...)` calls. It
skips the navigation when the screen on top is already that route, and leaves composers that take
re-delivered shares in place (`consumesSharesInPlace`) alone.
## Testing
The back stack is plain state, so tests drive `NavBackStacks` and `Nav` with `runTest`, no
Compose and no mocks: `NavBackStacksTest` (commons `commonTest`), and `NavBottomBarStackTest`,
`NavDrawerTest`, `NavImeSettleTest`, `NavDestinationsTest` (commonsUI `commonTest`, package
`com.vitorpamplona.amethyst.commons.ui.navigation`).