Author your screen flow as a graph — and get a real back button (Esc / hardware back / controller B), tabs that remember their stacks, and awaitable modals for free. Navigate by string id from anywhere:

csharp
LunaNavigation.Push("shop.home"); // show a screen var result = await LunaNavigation.PushAsync<bool>("confirm.delete"); // modal that returns a value LunaNavigation.PopBackStack(); // back, from code

The NavGraph canvas — a Switched container with its Tabs badge, two tab children with drill-down nodes, and containment edges with chevrons

This is Luna's screen push / pop stack. Use it for any UI flow with back-button semantics — modals, drill-downs, tab switches, full-screen takeovers.

Not Focus Navigation. That page is about arrow-key focus within a panel. This page is about pushing whole screens.

The five pieces

Navigation is split into a small set of orthogonal assets/components. Author them once, then navigate by string id forever after.

PieceWhat it isType
DestinationOne screen / modal / tab — its id + policyNavNode (a node inside a graph)
GraphThe set of destinations + their containmentNavGraphSO asset
CatalogBakes id → NavNode for runtime id resolutionNavDestinationCatalog asset (optional — see below)
HostScene component that mounts a graph as a live UI layerNavHost MonoBehaviour
ConfigThe PanelSettings (scale / theme / sort-order) a host appliesNavConfigSO asset

The contract that ties it all together is the id. NavNode.Id ⇒ the catalog key ⇒ LunaNavigation.Push("that.id"). Keep ids flat, globally unique, and dotted by convention (shop.home, hero.detail, settings).

NavGraphSO (asset) NavHost (scene) ──mounts──► graph ├─ NavNode "shop.home" ◄──┐ └─ NavConfigSO (PanelSettings) ├─ NavNode "shop.item" ───┘ containment edge (parent → child) └─ NavNode "shop.tabs" (ChildMode = Switched) ⇒ its children are tabs LunaNavigation.Push("shop.home") ──resolves off──► the merged topology of all mounted hosts

Why the split:

  • Graph ≠ layer. A graph is just the destination set + containment. Where/how it renders (PanelSettings, sort order, which scene) is the host + config — so one graph can be mounted by two hosts in two scenes with different configs.
  • Containment is an edge, not a field. A destination's parent is a graph edge. Tab-ness and the owning channel are derived from containment by NavTopology at load — never stored on the node.
  • One id space across hosts. Every mounted host registers its graph's topology into one merged index, so a destination defined once (e.g. a global settings) is reachable from every scene.

Dependencies (v2.3.2): the graph canvas comes from com.cupkekgames.graphs and the CatalogKey machinery from com.cupkekgames.data. Both are declared dependencies of com.cupkekgames.luna, so they arrive automatically when you install Luna — nothing extra to add.

Quick start

  1. Create → Luna → Nav → Nav Graph, double-click it to open the canvas, add a NavNode per destination, set each node's Id and Prefab, and wire containment edges.
  2. Create → Luna → Nav → Nav Config, assign the PanelSettings this layer renders with.
  3. In the scene: add a NavHost to a GameObject, assign the graph + config. Make sure a LunaUIManager is in the scene.
  4. Push from anywhere:
csharp
using CupkekGames.Data; using CupkekGames.Luna.Navigation; [CatalogKeyConstraint(NavConstants.NavDestinationCatalogId)] [SerializeField] private CatalogKey _heroDetail; // validated id dropdown void OnRosterRowClicked(Hero hero) { LunaNavigation.Push(_heroDetail, new HeroDetailArgs { Hero = hero }); // or, by raw id: LunaNavigation.Push("hero.detail", new HeroDetailArgs { Hero = hero }); }

Pop with Esc / back-button / backdrop click, or programmatically with LunaNavigation.PopBackStack().

Authoring the graph

Create with Project → Create → Luna → Nav → Nav Graph (one graph per feature/scene, e.g. RootNav, ShopNav). Double-click the asset to open the canvas (also reachable via Window → CupkekGames → Graphs → Graph Editor).

  • Add a NavNode per destination. Select a node to edit its fields in the docked inspector. The node's Id is the Push id / catalog key — the field autocompletes existing ids across every graph as you type, and double-clicking the node header renames it (which writes the id).
  • Containment edges — drag from a parent's children-out port (right edge) to a child's parent-in port (left edge). The chevron points parent → child.
    • A node with no inbound edge is a forest root — global modals, the shell, a tab bar. That's legal; a graph is a forest.
    • A node has at most one parent, and the canvas rejects edges that would create a cycle.
  • Tabs — set a container node's Child Mode = Switched. Its children become tabs (each owns its own parallel stack). Everything else stays Stacked (children share the parent's stack — drill-downs and overlays).
  • The graph footer flags missing id (warning), duplicate id (error), and more than one parent (error) live — the duplicate-id and multi-parent checks also run in NavTopology.Build at load.

The canvas badges policy at a glance: Tabs on a Switched container, seed for Start Visible, replace for Replace occlusion, backdrop, multi for multi-instance — plus a derived tab badge and a per-channel accent tint once the topology resolves.

Identity

  • Id — stable id for code / deep-link nav. Push("<id>") resolves to this destination. Flat, globally unique, dotted by convention. Required to be reachable by id.

Spawn

  • Prefab — the view prefab the host spawns. The root must carry a UIViewComponent (see prefab rules).
  • Start Visibletrue ⇒ born opacity:1 / display:flex and the host auto-pushes it at boot (sibling-order). false (default) ⇒ born hidden, off-stack until pushed.
  • Is Multi Instancefalse (default): re-push reuses the existing frame. true: each push spawns a fresh copy (notifications, side-by-side dialogs).

Behavior

  • Occlusion — when on top, does it hide the view beneath it in its own stack? Overlay (default — modals/overlays/toasts) keeps the one below visible; Replace (screen-replaces-screen) hides it. Ignored for tabs.
  • Dismiss Mode — reaction to Esc / hardware back / controller B / backdrop click. Pop (default) closes it; Block consumes the intent and does nothing (no fall-through); PassThrough registers no handler so input flows to whatever is below.
  • Reset State On Reopen — fire OnStateReset(args) before each reopen (2nd+ push). For confirm dialogs / compose forms that should look fresh every open.
  • Destroy On Pop — destroy the view's GameObject when closed. For loader-spawned ephemerals. Leave false for host-spawned views (the host-preloaded instance is always preserved regardless).
  • Disable Other Views On Fade In — block pointer + keyboard on other views while this is visible (default true). Keeps keyboard/gamepad focus from wandering behind a modal.

Containment

  • Child Mode — how this node's children attach. Stacked (default): children share this node's stack. Switched: each child owns its own parallel stack — this node is a tab container; its children are tabs.

Backdrop

  • Backdrop — element inserted behind the view: None (default), Dim (semi-transparent black; click pops the view when Dismiss Mode = Pop), or Custom.
  • Custom Backdrop AssetVisualTreeAsset cloned as backdrop content when Backdrop = Custom.

Catalog — id dropdowns and the runtime resolver

Reference destinations from inspectors with the house CatalogKey pattern:

csharp
[CatalogKeyConstraint(NavConstants.NavDestinationCatalogId)] [SerializeField] private CatalogKey _settingsDest;

In the editor this works with zero setup. NavDestinationAutoCatalog (editor-only, auto-registered) scans every NavGraphSO in the project and surfaces every NavNode.Id to those dropdowns — no catalog asset, no ServiceRegistrySO wiring. It is never live during play: runtime navigation resolves ids off the mounted hosts' merged NavTopology, not a catalog.

A NavDestinationCatalog asset is only needed when runtime code resolves a node object by id (e.g. via NavDestinationResolver to read a node's policy). Create it with Project → Create → Luna → Nav → Destination Catalog, add your graphs to its Graphs list, leave the catalog id at its default (NavConstants.NavDestinationCatalogId, "NavDestination"), and register it through a ServiceRegistrySO like any other catalog. Then:

csharp
NavNode node = NavDestinationResolver.Resolve("shop.home"); // or Resolve(catalogKey)

An unset CatalogKey has an empty Key; the nav verbs no-op on an unknown id (with a logged warning), so guard optional slots with !key.IsEmpty if you want them silently skipped.

Scene host setup

  1. Create → Luna → Nav → Nav Config. Assign the Panel Settings every view on this layer renders with (scale mode, theme, sorting order / z). The config is the single owner of physical render config — view prefabs must not carry their own PanelSettings.
  2. Add a NavHost component to a scene GameObject and assign:
    • Graph — the NavGraphSO to mount.
    • Nav Config — the NavConfigSO from step 1.
    • Gates All Ready — leave on (default) so a boot loading screen waits for this host via LunaLayers.AllReady. Turn off for hosts that load lazily (e.g. a HUD spawned after boot) so they don't hold the loader open; they still participate in PlayAll / HideAll.
    • Persist Across ScenesDontDestroyOnLoads the host (and its views). Use for a global UI graph.
    • BootOn Awake (default) spawns views in Awake. Manual still registers the host at Awake (so it counts toward LunaLayers.AllReady) but defers spawning until a sequencer node calls Boot() — used by the sequencer-driven loading screen so hosts spawn only after their services exist.
  3. Ensure a LunaUIManager is in the scene — drag Assets/Samples/LunaUI/<version>/Essentials/Prefabs/LunaUIManager.prefab in (it comes pre-wired with the effect settings, icon database, and audio), or add the LunaUIManager component to a GameObject manually — its required UIInteractableAudioHandler is added automatically via [RequireComponent]. It builds the nav stack in Awake at execution order −3000, before any host (default order 0). If you use a boot loading screen, LoadingScreen sits between them at −2000.
  4. Ensure an EventSystem exists in the scene (GameObject > UI > Event System), and assign a PlayerInput (or PlayerInputManager) on LunaUIManager for Esc / controller-B — the Essentials prefab ships that field empty (the Essentials Luna_InputSystem_Actions asset works as its Actions; the manager logs a one-time warning at startup if it's missing).

⚠️ No EventSystem, no clicks. UI Toolkit pointer dispatch needs an EventSystem in the scene — without one, clicks (including backdrop-click dismissal) never reach the views. Esc / controller-B go through the Input System instead, so they additionally need a PlayerInput (or PlayerInputManager) assigned on LunaUIManager — the Essentials prefab ships that field empty.

On play, the host: registers with LunaLayers → builds + registers its NavTopology into the merged index → spawns each node's prefab (parents before children, siblings in edge order, children parented under their spawned parent's GameObject) → applies the config's PanelSettings to every spawned view's PanelRenderer → load-gates on every view's panel → seeds the Start Visible nodes onto their stacks → reports ready.

NavHost API surface:

csharp
public bool IsReady { get; } // every view loaded + seed done public event Action OnReady; // fires once public IReadOnlyList<UIViewComponent> Views { get; } // spawn order public UIViewComponent GetView(string id); // spawned view for a node id public void WhenReady(Action action); // now-or-later convenience public bool IsBooted { get; } // views spawned (Boot ran) public void Boot(); // spawn views now — Manual boot mode public void PlayAll(); // push every owned node public void HideAll(); // close every owned node

LunaLayers is the static facade over every live host: All, AllReady, OnAllReady (rising-edge), WhenAllReady(Action), BootDeferred() (boots every Manual host), PlayAll(), HideAll().

Global UI (reachable from every scene)

Put always-available destinations (Settings, confirm modals, toasts) in their own NavGraph as forest roots, mounted on a NavHost with Persist Across Scenes = on (or placed in a boot scene). Each host registers its topology into the shared union, so Push("settings") resolves from any scene. There is no "global destination" type — it's a normal graph on a persistent host.

Multi-host merge

Every mounted host's topology is unioned into one id index; hosts unregister on destroy (scene unload). Ids must be unique across mounted graphs: a duplicate id logs an error and the first registration wins. Within one graph the canvas footer flags duplicates; across graphs the Problems panel of the Graph Runtime Debugger catches them at author time.

Prefab rules

Every destination prefab the host spawns must:

  • Have a UIViewComponent (or subclass) on its root. The host wires the node at spawn via UIViewComponent.SetNode — there is no destination field to assign on the prefab.
  • Carry no PanelSettings of its own. The host applies the NavConfigSO's settings to each spawned view's PanelRenderer. A stray PanelSettings is the usual cause of a view rendering at the wrong scale/sort.
  • Be saved active. The panel-reload callback only fires while the component is active — an inactive prefab never reports loaded and stalls the host's ready gate.

Drive it from code

All verbs live on the static LunaNavigation facade and take the destination's stable string id. Exact signatures:

csharp
public static class LunaNavigation { public static LunaNavigationStack Instance { get; } // LunaUIManager.Instance.Navigation public static void Push(string id, object args = null); public static void Push<TArgs>(string id, TArgs args) where TArgs : class; public static void ReplaceTop(string id, object args = null); public static void PopUpTo(string id, bool inclusive = false); public static void ClearStackAndPush(string id, object args = null); public static void PopBackStack(); public static void Close(string id); public static void SwitchChannel(string channelId); public static Task<PushResult<T>> PushAsync<T>(string id, object args = null); // CatalogKey overloads — each forwards its Key to the string verb above. public static void Push(CatalogKey key, object args = null); public static void Push<TArgs>(CatalogKey key, TArgs args) where TArgs : class; public static void ReplaceTop(CatalogKey key, object args = null); public static void PopUpTo(CatalogKey key, bool inclusive = false); public static void ClearStackAndPush(CatalogKey key, object args = null); public static void Close(CatalogKey key); public static void SwitchChannel(CatalogKey key); public static Task<PushResult<T>> PushAsync<T>(CatalogKey key, object args = null); public static void Pop<T>(T result); // resolves the top frame's awaiter public static void AddGuard(NavGuard guard); public static void RemoveGuard(NavGuard guard); // Boot hold (initial-load preloader pattern — see Loading-screen section) public static bool IsBootHeld { get; } public static void HoldBoot(); public static void ReleaseBoot(); }
VerbEffect
Push(id, args)Push the destination onto its stack (auto-switches channel if it's tab-bound). Unknown id → warning + no-op.
Push<TArgs>(id, args)Typed push — TArgs inferred from the call site (Push("hero.detail", new HeroDetailArgs(hero))).
ReplaceTop(id, args)Pop the top of the visible stack, then push.
PopBackStack()Pop the top of the active visible stack (bubble-up rules).
PopUpTo(id, inclusive)Pop the visible stack down to a destination.
ClearStackAndPush(id, args)Drain the visible stack, then push.
Close(id)Force-close a specific destination wherever it is (cascade-closes its descendants).
SwitchChannel(channelId)Switch the active tab. The id must be a tab (a Switched container's child).
await PushAsync<T>(id, args)Push a modal and await its result; Pop<T>(value) resolves it.

State reads and events live on the instance (LunaNavigation.Instance):

csharp
public NavNode CurrentNode { get; } // topmost visible destination public string CurrentChannelId { get; } // deepest active channel id (null = root only) public NavNode CurrentChannel { get; } public IReadOnlyList<string> ActiveChannelPath { get; } // outermost → innermost public int StackDepth { get; } // root + active path ("show back button when > 1") public object CurrentArgs { get; } public T GetArgs<T>() where T : class; public event Action<NavNode> DestinationChanged; public event Action<NavNode> ChannelChanged; public event Action<NavEvent> OnPushed; // payload: Node, Args, ChannelId public event Action<NavEvent> OnPopped; // fires on every removal (pop, cascade, scene unload)

Push routing

  1. Resolve the node's owning channel (nearest tab ancestor, derived from containment). Null = the root stack, which is always layered above channel content — use it for global modals.
  2. If the node is channel-bound and that channel isn't active, SwitchChannel fires first.
  3. Single-instance node already on a stack → the existing frame is kept, descendants above it cascade-close, args update, and OnStateReset fires if authored.
  4. Multi-instance node → a free instance is reused or a fresh copy spawns from the prefab.
  5. The frame lands on its stack and the view fades in.

Per-push args — typed POCOs

Define your own args class; no dictionary, no string keys.

csharp
public class HeroDetailArgs { public Hero Hero; public string Origin; } LunaNavigation.Push("hero.detail", new HeroDetailArgs { Hero = h, Origin = "leaderboard" });

The view reads its args off its own UIViewComponent. Remember the panel arrives asynchronously — do element queries in the OnUILoaded override (or via WhenUILoaded), and per-push refresh in a fade hook:

csharp
public class HeroDetailScreen : UIViewComponent { protected override void OnUILoaded(VisualElement root) { // Element queries are safe here: root is this view's subtree. UIView.Fade.OnFadeInStart += OnFadeInStart; } void OnFadeInStart() { var args = GetArgs<HeroDetailArgs>(); if (args != null) BindHero(args.Hero); } }

GetArgs<T>() returns null when args weren't set or aren't of type T. The same handler covers both.

Awaitable push

csharp
var result = await LunaNavigation.PushAsync<int>("shop.quantityPicker"); if (result.IsDismissed) return; ApplyQuantity(result.Value);

The modal's confirm button calls LunaNavigation.Pop<int>(amount); any other close path (Esc / cascade / scene unload / re-push) resolves the awaiter with IsDismissed = true. A type mismatch between PushAsync<T> and Pop<T> logs a warning and resolves as dismissed.

Guards

Pre-push interceptors. Guards are synchronous predicates by design — the delegate is NavGuardResult NavGuard(NavGuardContext context). They fire before every push in registration order; the first Block wins; a guard that throws is logged and treated as Allow.

csharp
LunaNavigation.AddGuard(ctx => { if (ctx.Node.Id == "account.delete" && !FeatureFlags.AllowDestructive) return NavGuardResult.Block; return NavGuardResult.Allow; });

For an async confirm flow (e.g. an "unsaved changes" modal), don't gate it in a guard — await at the call site and push conditionally:

csharp
if (await ConfirmDiscard()) LunaNavigation.Push("editor.next");

Channels (tabs + nesting)

A channel is a routing scope with its own back stack. You don't author channels directly — they're derived from graph shape: set a container node's Child Mode = Switched and each of its children becomes a tab that owns its own parallel stack. A destination's owning channel is its nearest tab ancestor (including itself).

shop.tabs (ChildMode = Switched) ← tab container ├── shop.home ← a tab (owns a stack) │ └── shop.item (lives on shop.home's stack) └── shop.cart ← a tab (owns a stack) └── shop.checkout (lives on shop.cart's stack)

Switching:

csharp
LunaNavigation.SwitchChannel("shop.home"); LunaNavigation.SwitchChannel("shop.cart"); // cross-fade; shop.home's stack is preserved

SwitchChannel walks the target's containment chain to build the new active path. Channels leaving the path hide their tops; channels entering show their tops (auto-pushing the tab itself as the base frame when their stack is empty). Dormant channels keep their stacks — switching back restores the drill-down exactly where the user left it.

The luna:TabView UXML element automates this: enter the tab node ids into its Destinations attribute (List<string>, in tab-button DOM order) and it calls SwitchChannel for you. See its Channels mode.

Bubble-up Pop

PopBackStack (and Esc) pops the deepest non-empty stack:

  1. Root stack first (global modals are layered above channels).
  2. Then the deepest active channel; if empty, walk up the active path.
  3. If popping empties a channel, that channel leaves the active path — its parent becomes the new deepest, and the next Esc bubbles there.

Multi-instance destinations

Set Is Multi Instance on the node. Each Push reuses a free live instance or spawns a fresh copy from the node's prefab; Pop destroys the copies (the host-preloaded original is always kept).

csharp
LunaNavigation.Push("toast", new ToastArgs { Text = "Saved", Tone = ToastTone.Success }); LunaNavigation.Push("toast", new ToastArgs { Text = "Auto-synced", Tone = ToastTone.Info }); // Two independent toasts visible at once.

This is also the dynamic screens pattern: one archetype node ("toast", "dialog.generic") + per-push args that drive the content. There's no per-push node creation — the node is the archetype, the args are the instance data.

UIPrefabLoader stays for one-off VFX (damage numbers, screen-shake) that aren't stack frames — use it for fire-and-forget effects, the nav stack for anything with back-button semantics.

Dismissal & backdrops

All dismiss intents — Esc, hardware back, controller B, backdrop click — are governed uniformly by the node's Dismiss Mode:

ModeEsc / back / BBackdrop click
Pop (default)Pops this destinationPops this destination
BlockConsumed, does nothing; does not fall throughSuppressed
PassThroughNo handler registered — input flows to whatever is below (parent frame, game-level shortcut)Suppressed

Programmatic PopBackStack() / Close(id) always work regardless of mode.

Backdrops are inserted as the first child of the view's root when the node authors one: Dim is a semi-transparent black layer; Custom clones the node's VisualTreeAsset. The element carries the USS class luna-nav-backdrop for styling overrides. Authoring contract: backdrop-using destinations need a panel-filling view root so the backdrop covers the screen — wrap centered-window modals in a panel-filling outer VisualElement.

State preservation

Host-spawned view GameObjects live for the host's lifetime. Push shows / Pop hides the visual state — the MonoBehaviour stays alive across cycles, so TextField text, ScrollView positions, and Animator states all persist between push and pop. Dormant tab channels likewise preserve their whole stack.

If a view should reset on every visit, enable Reset State On Reopen on the node and override OnStateReset(object args) in the view to clear + re-seed. It fires before each reopen (the 2nd and later pushes); the first push doesn't fire it — the view's state is fresh by construction.

Per-push lifecycle hooks come from FadeUIElement events on UIView.Fade:

EventFires whenUse for
OnFadeInStartFade-in beginsPer-push refresh — read args, update labels, repopulate lists.
OnFadeInFade-in completesFocus an element, fire analytics.
OnFadeOutStartFade-out beginsUnhook external subscriptions.
OnFadeOutFade-out completesFinal cleanup.

Loading-screen pattern (boot hold)

The boot loader is not a nav destination. LoadingScreen is authored as a scene-root GameObject with its own PanelRenderer + high-sort-order PanelSettings — putting it in a graph would entangle it with channel routing and push/pop lifecycles that don't belong to a boot overlay (it would also be load-gated by the very host it must wait on). It has two reveal paths, and only the first uses the boot hold.

Auto-reveal (single-scene boot). Enable the loader's Auto-reveal (boot loader, no sequencer) option (_autoRevealOnAllReady). The flow:

  1. LoadingScreen.Awake (execution order −2000, after LunaUIManager at −3000, before every NavHost at 0) calls LunaNavigation.HoldBoot() — from then on every push (host Start-Visible seeds, TabView auto-select, consumer code) queues instead of executing.
  2. Each NavHost spawns its views and load-gates; the loader sits on top by sorting order.
  3. The loader subscribes LunaLayers.WhenAllReady — when every gating host reports ready (and the configurable minimum visible time has elapsed), it fades itself out.
  4. After the fade completes, it calls LunaNavigation.ReleaseBoot() — the queued pushes drain in arrival order and run their full lifecycle (fade-in animations, OnFadeInStart handlers) after the loader is gone.

Sequencer-driven (scene loads / multi-scene boot). The loader is shown by a scene transition and hidden by a sequencer node — it does not hold boot. Author the hosts as NavHostBoot.Manual (they register at Awake so they count toward readiness, but defer spawning), then run BootNavGraphsNodeSO (→ LunaLayers.BootDeferred()) to boot them once services exist, followed by RevealLoadingScreenNodeSO to fade the loader out once LunaLayers.AllReady.

See Loading for both paths end to end.

Hosts that shouldn't hold the loader open (a lazily-spawned HUD) set Gates All Ready = off.

PushAsync awaiters created during the hold resolve as dismissed — don't await boot pushes.

Debugging

  • Canvas runtime overlay — keep the NavGraph canvas open during play. Nodes paint live: active (green, filled — the visible top), dormant (amber — an open tab whose stack is preserved but hidden), stacked ×N (dim — on a stack, covered by N frames), nothing — off every stack. Edges along the live path light up green.

  • Graph Runtime DebuggerTools → CupkekGames → Graph Runtime Debugger (from com.cupkekgames.graphs). Every mounted NavHost registers its graph there; the window's Problems panel also surfaces nav's author-time wiring checks: duplicate ids across a catalog's graphs, NavGraphSOs in no catalog, and catalogs in no ServiceRegistrySO.
  • Author-time graph errors (duplicate id, multi-parent, containment cycle) log from NavTopology.Build at the host's Awake; missing ids are flagged only in the graph footer (warning) — at load such nodes are silently unaddressable.

Limits — know before you build

  • No serializable back stack. The stack model is in-memory only; nav state is not saved/restored across sessions. Re-seed your UI on load.
  • No compile-time safety. Ids are strings (typos resolve at runtime as a logged warning + no-op) and args are object (a wrong-type read via GetArgs<T>() returns null).
  • Guards are sync-only. No awaiting inside a guard — await at the call site instead.
  • One graph per host. Cross-graph composition (sub-graph reference nodes) is deferred; keep a feature's destinations in one graph and reach across hosts via the merged id space, not edges.
  • PopUpTo / ReplaceTop / ClearStackAndPush operate on the visible stack only (root if non-empty, else the active channel). Cross-channel batch pops are out of scope — sequence per-channel verbs explicitly.

Common gotchas

  • "No UIView registered for destination 'X'" — no mounted NavHost owns that node (its graph isn't mounted in this scene, or the host hasn't awakened), or the node's Prefab is null. Global destinations belong on a Persist Across Scenes host.
  • "Push: no destination for id 'X'" — the id isn't in any mounted graph's topology. Check the spelling against the node's Id and that the owning host is in the scene.
  • Wrong scale / sort order — the prefab carries its own PanelSettings. Remove it; the host's NavConfigSO is the single owner.
  • "Duplicate destination id ... — first wins" — the same id exists in two mounted graphs. Rename one; the Graph Runtime Debugger's Problems panel catches this at author time.
  • SwitchChannel warns "not a tab" — the target id's parent isn't a Switched container. Tab-ness is derived from the parent's Child Mode, never set on the tab itself.
  • Esc closes my base view — set the node's Dismiss Mode to Block (consume) or PassThrough (defer to the handler below). Programmatic pops still work.
  • CatalogKey dropdown shows a yellow warning — the id isn't in any graph (or the picked id was renamed). Re-pick from the dropdown.

See also

  • Luna UI Manager — owns the navigation stack
  • UIViewComponent — the per-view component (OnUILoaded, WhenUILoaded, GetArgs<T>)
  • Focus Navigation — arrow-key focus traversal
  • UI Actions — Esc-only modal flows that don't need a stack
  • Source: Runtime/Scripts/Navigation/ in the Luna package

Reading along without the package? The nav system ships with everything else in Luna — get it on the Asset Store.

Settings

Theme

Light

Contrast

Material

Dark

Dim

Material Dark

System

Sidebar(Light & Contrast only)

Light
Dark

Font Family

DM Sans

Wix

Inclusive Sans

AR One Sans

Direction

LTR
RTL