Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Routing & Navigation — the contract for the declarative shell

This is the single source of truth for how navigation works in a C_YUI_SHELL Single-Page Application (gui_treedb, gui_agent, wattyzer, …), and the rules every new visual element MUST follow. A SPA has to simulate a Multi-Page Application: every point in the app is reachable by a URL, the path the user walked is recorded so Back/Forward work, and reloading or sharing a URL lands on exactly the same place.

If you are adding anything a user can see or reach, read the Litmus (§3) and the Implementer checklist (§8). Everything else is the why.


1. The model (axioms)

  1. The URL is the single source of truth for where the user is. Every visible position derives from the URL. A state that is not encoded in the URL does not exist for Back/Forward, reload, bookmark, or share — it is invisible to the mechanism that makes a SPA feel like an MPA.

  2. The route tree is a filesystem tree. Routes are paths: /<workspace>/<section>/<item>/<subitem>…. Every navigable node has a path, and paths nest exactly like directories. A parent path is a real, resting place; a child path is reachable directly (deep link) and by walking in.

  3. Navigation flows one way: intent → URL → view. A click, a tab, a toggle never mutates the view directly. It changes the URL; the shell’s hashchange handler then mounts/updates the view. Browser Back/Forward use the same code path, so they are correct by construction — you never write “handle Back” logic, you just make every move go through the URL.

  4. History is the browser’s stack (LIFO). Back pops the most-recent entry, Forward re-pushes it. window.history is that stack. Do not build a parallel route-history stack in a gclass or in localStorage. (The one auxiliary stack the shell keeps — the overlay stack, §6 — is a different concern layered on top, not route history.)


2. push vs replace vs touch-nothing

Every URL change is one of three kinds. Choosing the wrong one is the most common routing bug (it silently breaks Back).

SituationHistory opWhy
The user moves to a new place (opens a tab, a topic, a card, toggles a landing view)pushAdds an entry so Back returns to where they were.
A redirect / normalize / restore (unknown route → default, submenu parent → its default child, F5 re-landing on the last tab, “reflect a state we’re already in”)replaceMust NOT add a bogus entry the user never chose.
Reacting to a hashchange the browser already made (an <a href="#…"> click, a Back/Forward)touch nothingThe entry already exists; just mount the view.

Rule of thumb: if a human deliberately chose to go there, it’s a push. If code decided for them (to fix up or restore the URL), it’s a replace.


3. The Litmus — which bucket is a state in?

Before adding any visual state, classify it. This decides where it lives.

BucketQuestion it answersWhere it livesExamples
PositionWhat am I looking at?”The URL. Always.which workspace, which tab (incl. the connections/picker tab), which topic, the table-vs-info-vs-schema landing, a focused node
PreferenceHow do I like it shown?”localStorage (SDF_PERSIST attr) — not the URLgraph layout, graph operation-mode, theme, table page-size, column widths
Transient / overlay“A thing floating over the page right now”in-view state + the overlay stack (§6) — never a routea modal, the raw-JSON viewer, a popover, a hover/selection highlight

Corollaries:


4. The route tree


5. How a view participates

The shell emits two events (both carry the full picture):

A view’s contract:

  1. On EV_ROUTE_CHANGED for its base, apply subpath to its own state (show that topic / info / schema / focus). An empty subpath means “the view’s home” — reset to it (this is what makes Back from a deep sub-state return to the landing).

  2. When the user changes the view’s own position, navigate (push) so the URL and history reflect it — never just mutate priv and re-render. Emitting an intent the host turns into a route is fine; silent in-view state is not.

  3. Stay route-agnostic if you are a reusable library gclass: take host-supplied hash templates (e.g. card_action_routes, back_route) or publish an intent; let the host own the concrete paths.

  4. Declare your deep sub-routes to the site map (optional but encouraged): a view that owns dynamic subpaths (topics, /info, /schema, focus topics) calls yui_shell_set_sub_routes(shell, base_route, nodes) when they become known (e.g. after its schema loads), and clears them on mt_stop (…, null). nodes is an ordered [{route, label, icon?, children?}] (full hashless routes). The site map (§site map) then shows the complete tree, not just the declared skeleton. It is a pull-at-render registry: the map reads it live, so an unmounted view’s children vanish automatically. The view still stays route-agnostic — it builds the full routes from its host-supplied base_route.

  5. Declare who handles an action event (optional): a gclass that handles a toolbar/account action event calls yui_shell_register_event_handler(shell, event, gclass) once (next to its gobj_subscribe_event), so the site map shows where the action is implemented. Same pull-at-render registry idea as §5.4 — the shell can’t know the runtime subscriber statically, so the handler self-declares.


6. Overlays (modals, popups, floating windows)

Overlays are not route nodes — they float above the resting route. The shell integrates them with Back via a synthetic history entry (pushState with the same hash) and an overlay stack:

Use this for every modal/popup. Never encode an overlay as a route (a deep-linkable modal is an action route with redirect:"stay", a deliberate, rare exception — see the shell’s action-route handling).


7. The navigation APIs

7.1 Action routes — a route that fires an event instead of mounting a view

A route whose target is {"kind": "action", "event": "EV_…", "redirect": "…"} (declared in config.shell.routes, or as an item’s target) makes the shell publish that event instead of mounting a view. The route is transient: no view is mounted and current_route stays on the underlying resting view.

Two ways to wire an action, and they are not interchangeable:

action: {type:"event", event:"EV_…"} on the iteman action route + action: {type:"navigate", route:"/…"}
Has a URLnoyes
Deep-linkable / bookmarkablenoyes (the user can type the hash)
Appears in the site map as a routeno (only as an event)yes
Use it fora pure command with nothing to link to (toggle theme, toggle language)an action a user may want to reach by URL (About, Preferences, a dev panel)

Both are supported. Pick one idiom per app and keep the menu consistent — gui_agent and gui_treedb wire their account menus with type:"event"; wattyzer routes everything (/about, /devtools, /sitemap). A menu that mixes them reads as an accident.

redirect — what the URL does after the event fires:

redirectWhat the shell doesUse for
"back"Navigate to the previous resting view route. The URL never lingers on the action route.A floating window / panel the app opens itself (/devtools, /sitemap).
"stay"Keep the URL on this route so it is deep-linkable. The URL is not restored.A modal the app closes through a helper that puts the URL back (see the trap below).
"<route>"Navigate to that route afterwards.logout → "/".
"none" / ""No navigation; just replaceState the URL back to the previous resting route. The app takes over.The app tears the shell down itself (logout).

The stay trap — this is the one that bites. stay does not restore the URL: “the app’s overlay close path is responsible for history.back() (c_yui_shell.js). So stay is only correct when whatever opens the overlay also takes the URL back off the route on close. wattyzer’s open_route_modal does exactly that (its on_close calls history.back()), which is why /about and /user/preference are stay. A view opened by a plain helper — e.g. yui_shell_show_route_map, which just builds a C_YUI_WINDOW and registers it on the overlay stack (§6) — has no such hook: with stay the window closes and the URL sits on /sitemap forever, pointing at nothing. Use back for those. Rule of thumb: stay only if the opener owns the URL on close; otherwise back.

Deep-linking straight onto a stay route (or reloading on it) is handled: the shell mounts the default view underneath first, then re-pushes the hash on top, so a later close → Back lands on the default instead of exiting the app.

The rest of the configuration vocabulary — zones, stages, menu, toolbar, items[], lifecycle (eager / keep_alive / lazy_destroy), the submenu.index / submenu.default pair — is not repeated here: it lives in SHELL.md §3. Read it before adding a route. In particular submenu.index decides whether a primary’s own route is a real resting page or redirects to a child, which is the difference between /system (index: it stays) and /devices (no index: it redirects to its first child).


8. Implementer checklist (every new visual element)


9. Conformance status

The shell’s engine (route index, resolution, subpath, overlay stack, the nav-click → location.hash push path) implements this contract.

  1. push vs replace on programmatic navigationdone. Every consumer is explicit and the default is now push; {replace:true} marks the code-decided moves. Per consumer:

    • gui_treedb — user moves (topic/mode select, ← topics, “manage connections”) push; its four c_app.js fix-ups (deselected tab, F5 re-land, deep-link auto-open, workspace → first tab) are {replace:true}.

    • gui_agent — audited: it has no programmatic user moves at all (its moves are nav clicks, which push through the shell). All three sites are redirects and carry {replace:true}.

    • wattyzer — user moves (device/monitoring card → history, history ← realtime, treedb topic/mode select) push; nav_back_or_default()'s root fallback is {replace:true}.

    Note the asymmetry that justifies the default: a forgotten {push} silently broke Back (the bug this debt was about), while a forgotten {replace} only leaves a redundant history entry. The default is the failure-tolerant one.

  2. Connections tab as a remembered positionfixed (gui_treedb): the picker is recorded as a first-class active position (CONNECTIONS_TAB sentinel), so re-entering a workspace returns to it; active_tabs now mirrors the URL instead of excluding the picker.

  3. Topics “Schema” landing is a routefixed: /topics/db/<sel>/schema; the toggle is a push navigation (host-supplied landing_routes), apply_seg maps schemaEV_SET_LANDING_VIEW, the bare tab route resets to cards, so F5/Back/deep-link all work.

  4. Graph operation_mode / layout live in localStorage only — acceptable under §3 (they are preferences), recorded here as a deliberate decision.

A site-map viewer (shell_route_map.js, yui_shell_show_route_map, on yui_shell_nav_map) renders the WHOLE navigation surface (§4) — toolbar + account menu + primary nav + dynamic tabs, in declaration order — as a printable, clickable map that doubles as the app’s basic documentation. Views that use the sub-route contributor protocol (§5.4) also appear with their deep levels (topics, /info, /schema, focus topics), so the map is the complete tree.


Home of this contract: kernel/js/gobj-ui/ROUTING.md — this file is the published chapter (doc.yuneta.io/routing includes it from here, so there is no copy to drift), alongside SHELL.md at doc.yuneta.io/shell. Keep it in sync with the shell: an edit here ships on the next docs/doc.yuneta.io/deploy.sh.