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.

C_YUI_SHELL + C_YUI_NAV — Declarative shell

Application-level presentation and navigation system for Yuneta GUIs. Replaced the legacy C_YUI_MAIN + C_YUI_ROUTING pair (removed in 3.0.0; the frozen v1 npm line still ships them) with a couple of GClasses driven by a JSON document in the Yuneta configuration style.


0. Philosophy — declarative first

The shell is the practical expression of a wider Yuneta-side stance: apps describe what they want; the framework decides how to paint it.

  1. The application declares in app_config.json: zones, menus, toolbar items, view bindings, breakpoints, hover tooltips.

  2. gobj-ui materialises the DOM and owns all CSS for shell chrome (toolbar, drawers, dropdowns, navs). Apps do not poke into the shell’s elements; they do not ship media queries that target shell selectors.

  3. Imperative helpers are escape hatchesyui_shell_set_avatar_provider, yui_shell_refresh_avatars, yui_shell_close_dropdown, etc. They exist for the cases the declarative shape can’t reach (e.g. host-owned data like the user’s initials). They are not the default path.

When you reach for a feature, climb this ladder in order:

a. Can it be expressed by extending the JSON contract (a new field or a new type/action.type value)? Do that. b. If not, can a small public helper in gobj-ui cover it without leaking DOM details to the host? Add the helper. c. Touching app DOM/CSS directly is a last resort and signals a gap in (a) or (b) that should be filed back upstream.

The reverse migration is the litmus test: every time wattyzer or another consumer can delete an install_*() patch or a CSS selector keyed on data-toolbar-item-id, the shell got better at its job.


1. Goals

  1. Declarative: the screen split and the menu tree are described in a JSON document, not in imperative JavaScript.

  2. Per-zone responsive: be able to say “the primary menu lives in left on desktop and in bottom on mobile” without duplicating the menu definition or breaking the panels’ internal state.

  3. Two-level navigation: primary options + sub-options, mapped to hash routes (#/primary/secondary). Apps that need a third level of grouping inside the secondary use type:"header" / type:"divider" decorative items (§3.5) — visual chunking without a third nav level.

  4. Pluggable per-zone rendering: the same menu option must be able to render differently depending on where it lands (vertical icon + label in left; icon-over-label in bottom; horizontal tabs in top-sub; vertical list in right; etc.).

  5. Each view is a gobj with its own $container: the shell does not know what is inside — it just mounts it, shows it, and hides it. Navigating means “show a gobj in its zone and hide the one that was there”.

  6. Per-option lifecycle: eager / keep_alive / lazy_destroy, to balance the cost of rebuilding against RAM usage.

  7. No regressions on gobj-ui: existing components were left untouched while the shell proved itself. (Historical constraint: once every consumer had migrated, C_YUI_MAIN / C_YUI_ROUTING / C_YUI_TABS were removed in 3.0.0.)


2. Model

Three orthogonal concepts:

What goes in each zone

ZoneTypical use
topfixed toolbar (logo, theme, language, user)
top-subsubmenu rendered as tabs on mobile
leftprimary menu rendered as vertical on desktop
centerprimary stage: active gobj
rightsubmenu as vertical on desktop, or a secondary stage
bottom-subsecondary toolbar or tabs on mobile
bottomprimary menu rendered as icon-bar on mobile

The navigation tree vs. the route index

People say “the route tree”, but that name hides that there are two distinct artifacts, joined by the route string:

  1. The navigation tree — declarative, hierarchical, lives in app_config.json. This is the part that is actually a tree: menu.<id>items[] → (optional) submenuitems[]. Fixed at two routable levels (see §3.3 / §3.5).

  2. The route index — flat, built at runtime. On startup the shell flattens the navigation tree into a Map: "/path"{ item, parent_item, target, stage, menu_id }. This is what yui_shell_navigate() / hashchange looks up. It is not a tree — it is a lookup table (see §4 step 1).

The route ("/dash/ov") is not the destination. It is the flattened key that links a tree node to its index entry and to the URL hash. The destination is target → gclass mounted on a stage.

NAVIGATION TREE  (config, hierarchical)        ROUTE INDEX  (runtime, flat)
─────────────────────────────────────          ───────────────────────────
app_config.json
└─ menu
   ├─ primary
   │  ├─ "dash"  route:/dash   (container, ─┐
   │  │          no target)                 │  /dash        → target:∅
   │  │  └─ submenu                          │                 (redirects to
   │  │     ├─ "ov"     /dash/ov  ───────────┼─► /dash/ov     → C_TEST_VIEW
   │  │     │           target,stage:main    │                  @main keep_alive
   │  │     └─ "alerts" /dash/alerts ────────┼─► /dash/alerts → C_TEST_VIEW
   │  │                 target,stage:main    │                  @main lazy_destroy
   │  └─ "settings" /settings ───────────────┼─► /settings    → C_TEST_VIEW
   │                 target,stage:main       │                  @main keep_alive
   └─ quick
      └─ "q-ov" route:/dash/ov  ─────────────┘  (same key → same index
                                                 entry → reuses the one
                                                 instance, no duplicate)

Runtime resolution of one navigation:

click ─► EV_NAV_CLICKED{route} ─► lookup route in the route index
                                     │
                     ┌───────────────┴────────────────┐
               target is ∅                       gobj already
               (container)                       mounted in stage?
                     │                                 │
            redirect to 1st child          yes ─► reuse ($container.show)
            or submenu.default             no  ─► gobj_create(target.gclass)
                                                  in target.stage, start
                                                  │
                                       EV_ROUTE_CHANGED{route,item,stage}

Terminology used in this document:

Loose nameWhat it actually isPrecise term
“the route tree”menu.*.items[].submenu in the confignavigation tree
“the routes”the flat route → {...} Map at runtimeroute index
“open a route”route → target → gobj @ stagemount / binding

Orthogonal to all of the above are zones (CSS grid regions) and stages (zones that host routed gobjs) — they share the shell config block but are not part of the navigation tree. C_YUI_PAGER / C_YUI_WIZARD are outside the tree as well: they are stacked navigators a view mounts inside its own stage; they have no entry in the route index (see §6).


3. Configuration JSON

Minimal schema:

{
  "shell": {
    "zones":  { ... how zones are used ...   },
    "stages": { ... which stages exist ...   }
  },
  "menu": {
    "primary": { "render": { ... }, "items": [ ... ] }
  },
  "toolbar": { "zone": "top", "items": [ ... ] }
}

3.1 shell.zones

Each zone may declare:

The shell translates the expression into a set of custom CSS classes that hide the zone per breakpoint. Bulma only ships “up-to” helpers (is-hidden-tablet, is-hidden-desktop) — to be able to say “hidden only on tablet” gobj-ui adds these classes in c_yui_shell.css:

ClassHidden when
.yui-hidden-mobile<769 px
.yui-hidden-tablet-only769–1023 px
.yui-hidden-desktop-only1024–1215 px
.yui-hidden-widescreen-only1216–1407 px
.yui-hidden-fullhd≥1408 px

No classes outside this table are expected in show_on. The parser is pure and is covered by tests/shell_show_on.test.mjs (npm test in gobj-ui/).

Example:

"zones": {
  "top":     { "host": "toolbar",        "show_on": ">=tablet" },
  "left":    { "host": "menu.primary",   "show_on": ">=desktop" },
  "bottom":  { "host": "menu.primary",   "show_on": "<desktop"  },
  "top-sub": { "host": "menu.secondary", "show_on": "<desktop"  },
  "right":   { "host": "menu.secondary", "show_on": ">=desktop" },
  "center":  { "host": "stage.main" }
}

The same primary-menu option is instantiated twice (in left and in bottom) and shown/hidden via CSS. This avoids moving DOM nodes when crossing breakpoints, which would break their internal state.

3.2 shell.stages

"stages": {
  "main": { "zone": "center", "default_route": "/dash/ov" }
}

A stage is inferred automatically when a zone declares "host": "stage.<name>", so shell.stages may be omitted when there is only one main stage named main in center.

3.3 menu.<id>

"primary": {
  "render": {
    "left":   { "layout": "vertical", "icon_pos": "left", "show_label": true },
    "bottom": { "layout": "icon-bar", "icon_pos": "top",  "show_label": true }
  },
  "items": [ ... ]
}

3.4 toolbar

Cheatsheet — three orthogonal axes per item:

AxisFieldValuesEffect
Formtype"brand" · "avatar" · "connection" · omitted (action)Renderer kind
Behaviouraction.type"navigate" · "drawer" · "event" · "dropdown"What the click does
Secondarycontext_actionsame shape as actionRight-click (contextmenu) behaviour
Visibilityshow_on">=tablet" · "<desktop" · "*" · ...Per-item breakpoint

Compose freely — type:"avatar" + action:{type:"dropdown",items:[…]} is the canonical user menu. Dropdown sub-items reuse the same action.type set (no nested dropdowns). Brand items render as a passive <div> when action is omitted (focus-skipped, no click); include an action (typically navigate) to make the brand clickable. Avatar items ignore icon and name — the badge text comes from the host-registered provider; use aria_label for the menu label.

"toolbar": {
  "zone": "top",
  "aria_label": "App toolbar",
  "items": [
    { "id": "burger", "icon": "icon-menu",
      "show_on": "<desktop",
      "aria_label": "Open menu",
      "action": { "type": "drawer", "op": "toggle", "menu_id": "quick" } },
    { "id": "brand",  "type": "brand",
      "logo": "/wattyzer-mark.svg", "wordmark": "Wattyzer",
      "action": { "type": "navigate", "route": "/welcome" } },
    { "id": "home",   "icon": "icon-home",  "name": "Home",
      "action": { "type": "navigate", "route": "/dash/ov" } },
    { "id": "search", "icon": "icon-search",
      "show_on": ">=tablet",
      "action": { "type": "event", "event": "EV_OPEN_SEARCH" } },
    { "id": "user",   "type": "avatar",  "align": "end",
      "aria_label": "User menu",
      "action": {
        "type": "dropdown",
        "items": [
          { "id": "profile",  "name": "My profile", "icon": "icon-user",
            "action": { "type": "navigate", "route": "/account/profile" } },
          { "type": "divider" },
          { "id": "theme",    "name": "Theme",     "icon": "icon-moon",
            "action": { "type": "event", "event": "EV_CYCLE_THEME" } },
          { "id": "lang",     "name": "Language",  "icon": "icon-translate",
            "action": { "type": "event", "event": "EV_CYCLE_LANGUAGE" } },
          { "type": "divider" },
          { "id": "logout",   "name": "Logout",    "icon": "icon-logout",
            "action": { "type": "event", "event": "EV_LOGOUT" } }
        ]
      } }
  ]
}

3.5 items[] — option structure

{
  "id": "ov",
  "name": "Overview",
  "icon": "icon-eye",
  "route": "/dash/ov",
  "disabled": false,
  "badge": "3",
  "target": {
    "stage": "main",
    "gclass": "C_DASHBOARD_OVERVIEW",
    "kw": { "refresh_ms": 5000 },
    "lifecycle": "keep_alive"
  },
  "submenu": {
    "render": { "top-sub": "tabs", "right": "vertical" },
    "default": "/dash/ov/overview",
    "items": [ ... ]
  }
}

Decorative items inside a submenu — type:"header" / type:"divider"

When a secondary nav has many leaves, group them visually with two non-interactive item kinds. The shell’s route indexer ignores them (no route, no target); the renderers in vertical, submenu, drawer and accordion paint them; tabs and icon-bar silently drop them — there is no room for section labels in those compact layouts.

"submenu": {
  "render": { "top-sub": "tabs", "right": "vertical" },
  "items": [
    { "type": "header",  "name": "account" },
    { "id": "profile",  "name": "my profile",
      "route": "/system/account/profile",
      "target": { "stage": "main", "gclass": "C_TEST_VIEW",
                  "kw": { "title": "my profile" } } },
    { "id": "sessions", "name": "sessions",
      "route": "/system/account/sessions",
      "target": { "stage": "main", "gclass": "C_TEST_VIEW",
                  "kw": { "title": "sessions" } } },
    { "type": "divider" },
    { "type": "header",  "name": "infrastructure" },
    { "id": "tariffs",  "name": "tariffs",
      "route": "/system/infra/tariffs",
      "target": { "stage": "main", "gclass": "C_TEST_VIEW",
                  "kw": { "title": "tariffs" } } }
  ]
}

Field rules:

Both kinds carry no route/target/submenu, so they are skipped by enter_route, by the route index, and by the click handler (closest("[data-route]") returns nothing).

This lets an app keep a flat 2-level navigation tree (one of the shell’s deliberate constraints, see §1 goal #3) while still expressing more than two levels of meaning — the third level is purely visual chunking inside the secondary nav.

3.6 menu.<id>.render[zone].layout — when each layout is appropriate

LayoutBest zone(s)Renders header/divider?Notes
verticalleft, rightyesBulma .menu. Default secondary.
submenuright, top-subyesVertical list with a heading on top.
draweroverlay (off-canvas)yes (delegates to vertical)Toggled by toolbar burger.
accordionleftyes (in inner items)Collapsible groups; first-level entries are sections, not decorations.
tabstop-subno — silently droppedHorizontal strip; no room for labels.
icon-barbottomno — silently droppedMobile primary; one slot per icon.

4. Lifecycle

When a route is activated, the shell:

  1. Looks the entry up in its route → { item, parent_item, target, stage } index.

  2. Hides the previous gobj’s $container in that stage (is-hidden).

  3. If the previous item had lifecycle: "lazy_destroy", destroys it.

  4. If the new one does not yet exist, creates it with target.gclass

    • target.kw, appends it to the stage’s DOM, and starts it.

  5. Removes is-hidden from the new one, publishes EV_ROUTE_CHANGED, and synchronises the hash.

lifecycle modes:

ModeCreatedOn exitUse case
keep_alive1st visithidden (state preserved)heavy views or views with half-filled forms
lazy_destroy1st visitdestroyedoccasional views, avoids accumulating RAM
eagerat startuphiddenviews that must “be there” from the beginning

Recommended default: keep_alive.


5. Navigation


6. GClasses

C_YUI_SHELL

AttributeTypeDescription
configJSONThe JSON document described above
default_routestringFallback when the hash is empty and no stages.*.default_route is set
current_routestringRead-only: active route
use_hashboolIf true, syncs window.location.hash
mount_elementHTMLElementWhere to mount the shell (default document.body)
$containerHTMLElementShell root

Published events:

The pair requested ↔ changed is the canonical Yuneta way to audit a gobj’s behaviour: every intent is recorded regardless of outcome, and every successful state transition has its own event.

Public helpers (import from @yuneta/gobj-ui):

The shell paints into priv.layers.notification and priv.layers.modal. Two naming conventions:

import {
    yui_shell_show_info, yui_shell_show_warning, yui_shell_show_error,
    yui_shell_show_modal,
    yui_shell_confirm_ok, yui_shell_confirm_yesno, yui_shell_confirm_yesnocancel,
} from "@yuneta/gobj-ui";

/*  Toasts (Bulma .notification, auto-dismiss after 5 s). */
yui_shell_show_info(shell,    "Hello");
yui_shell_show_warning(shell, "Watch out");
yui_shell_show_error(shell,   "Boom");

/*  Non-blocking modal (Bulma .modal-content + .box).  Click on
 *  background, the close button or Escape close it.  `content` may
 *  be an HTMLElement (mounted as-is).  opts: { dismiss_on_background,
 *  with_close_button, on_close }.  with_close_button:false omits the
 *  external floating Bulma `.modal-close` (for content that provides
 *  its own in-box close, e.g. a C_YUI_PAGER header); Escape and the
 *  backdrop still close.  on_close() fires once after the modal is
 *  removed by ANY path (programmatic, Escape, backdrop, X) — use it
 *  to destroy a gobj you mounted inside. */
let { close } = yui_shell_show_modal(shell, $el, { on_close: cleanup });

/*  Blocking dialogs.  Escape, the close button, and click on the
 *  background all resolve with the LAST button's value (cancel/no/
 *  ok by convention — the safe-default action). */
await yui_shell_confirm_ok(shell, "Saved.");
let yes = await yui_shell_confirm_yesno(shell, "Discard changes?");
let r   = await yui_shell_confirm_yesnocancel(shell, "Save before close?");
//   r === "yes" | "no" | "cancel"

Every modal/dialog automatically:

The legacy display_* / get_yes* helpers were removed with c_yui_main.js in 3.0.0 (see §10); their icon-centric card design lives on in the yui_shell_confirm_* dialogs.

Internationalisation

The shell does not own i18n. Every translatable text node it renders (menu labels, secondary-nav heading, toolbar buttons) is emitted with data-i18n="<canonical English key>" via createElement2’s i18n attribute. To switch languages, use the canonical helper from @yuneta/gobj-js:

import { refresh_language } from "@yuneta/gobj-js";
import i18next, { t } from "i18next";

i18next.changeLanguage("es");
refresh_language(shell.$container, t);

Apps that already configure i18next get the shell labels switched for free; apps without i18next can pass any (key) => string function as t.

C_YUI_NAV

Instantiated by the shell (one per menu, zone pair). End users do not normally create it directly. The nav does not navigate: it publishes the intent and the shell routes.

Published events:

Notable attributes: menu_items, zone, layout, icon_pos, show_label, level (primary | secondary), shell, nav_label (human-readable label used by the secondary navs as their heading and aria-label; the shell fills it from the parent item’s name).

Secondary nav menu_id

The shell auto-instantiates a level-2 nav for every primary-style menu item that declares a submenu with its own render block — not just menu.primary. The synthesised menu_id for a secondary nav is secondary.<owning_menu_id>.<item.id>, so two primary-style menus may have items with the same id without colliding (e.g. secondary.primary.dash and secondary.admin.dash are independent navs that flip visibility based on the active route’s owning menu).

i18n

The nav has no translate attr — it does not translate text itself. Labels are emitted with data-i18n and a single refresh_language call on the shell’s $container re-translates every nav at once.

C_YUI_PAGER

Drill-down navigation stack (Pattern A: settings-style, mobile-first). Container-agnostic: it owns only the navigation chrome (a <- title header + a body that stacks panels). The parent mounts gobj_read_attr(pager, "$container") wherever it wants — inside a C_YUI_WINDOW body, a Bulma modal-card body, or inline — and feeds content with EV_PUSH_PAGE. No Confirm/Cancel chrome on purpose: the content panel auto-saves itself (e.g. C_YUI_FORM); <- back only navigates.

Subscription model: CHILD (created with a parent; the parent — or an explicit subscriber attr — receives the output events).

Input events:

Published events:

Notable attributes: subscriber, root_title, back_on_root (default true), with_discard (default false; the per-page discard affordance only shows when this is true and the pushed page set discardable:true), $container (internal, the node the parent mounts).

Header affordance by depth: a deeper page shows a back arrow (yi-arrow-left, pops); the root page with back_on_root shows a close cross (yi-xmark, emits EV_PAGER_EXIT). yi-arrow-left / yi-arrow-right were added to yui_icons.css for this (the set is small — verify a glyph is defined there before using it). The pager_header_model exposes this as back_kind ("back"|"close"|"none").

Host note: the close affordance lives inside the popup (the pager header). When hosting in yui_shell_show_modal, pass with_close_button: false so the external floating Bulma .modal-close is omitted and the pager’s in-box cross is the only close (Escape and the backdrop still close). When mounted inside a Bulma modal-card, hide the modal-card-head to avoid a double header. On mobile a full-screen sheet is recommended; on desktop a centred card/box. No transitions on push/pop by design.

C_YUI_WIZARD

Multi-step wizard (Pattern B: forward/back with a final confirm). Same container-agnostic philosophy as C_YUI_PAGER and the same CHILD subscription model. Unlike the pager, a wizard does confirm at the end. The parent supplies all steps up-front; the gclass owns the title / N / M counter / Back / Next(Confirm) chrome and shows one step at a time.

Input events:

Published events:

Validation contract: in a linear wizard every step whose content is a gobj must handle EV_STEP_VALIDATE and reply EV_STEP_VALID/EV_STEP_INVALID. Steps that are plain elements, and non-linear wizards, skip validation and advance immediately.

Notable attributes: subscriber, confirm_label (default "confirm"), next_label ("next"), back_label ("back"), allow_back (default true), linear (default true), $container (internal). Same modal-card host note as C_YUI_PAGER (hide modal-card-head; full-screen sheet on mobile; no transitions).


7. Integration in an app

import {
    register_c_yui_shell,
    register_c_yui_nav,
} from "@yuneta/gobj-ui";
import "@yuneta/gobj-ui/src/c_yui_shell.css";
import "bulma/css/bulma.css";
import app_config from "./app_config.json";

register_c_yui_shell();
register_c_yui_nav();
/*  also: register_c_<your_view>() for every gclass referenced in target.gclass */

gobj_create_default_service(
    "shell",
    "C_YUI_SHELL",
    { config: app_config, use_hash: true },
    yuno
);

Every view GClass must expose a $container attribute holding a root HTMLElement; the shell takes care of appending it to the stage and managing its visibility.


8. Solution vs. the original requirements

RequirementHow it is covered
Declarative, Yuneta-style JSONconfig attribute holding a JSON of shell/menu/toolbar
Layers + working zones6 fixed layers + 7 zones in a grid
Two-level primary menumenu.primary.items[].submenu.items[]
Three-level meaning without a third nav leveltype:"header" / type:"divider" decorative items inside submenu.items[] (§3.5)
Primary in left on desktop and bottom on mobile"host": "menu.primary" in both, with opposite show_on
Icon + label; different per zonerender[zone] with layout + icon_pos + show_label
Submenu as tabs or as a side submenusubmenu.render[zone] set to "tabs" / "vertical" / etc.
Fixed toolbar at top or bottomshell.zones.top.host = "toolbar" (or bottom)
Built on top of Bulma helpers.menu, .tabs, .level, is-hidden-*; our own CSS only for icon-bar, drawer, accordion, and the per-breakpoint hiders
Vite-compatibleSame flow as the rest of gobj-ui
Drop-in for libyui laterRe-exported from index.js

9. Test app

A runnable, backend-less layout catalog lives in test-app/. It is the practical companion to this document: every C_YUI_NAV layout on one screen, driven entirely by test-app/src/app_config.json. See test-app/README.md. Quick start:

cd kernel/js/gobj-ui/test-app
npm install
npm run dev        # http://localhost:5173

Every leaf mounts one small view (C_TEST_VIEW) that names on screen which layout(s) are visible and where, and prints an instance # counter so keep_alive vs lazy_destroy is observable.

The test-app navigation tree, flattened

A concrete instance of the §2 model on the shipping test-app/src/app_config.json — a pure 2-level menu with inline targets and toolbar actions of type navigate / event / drawer / dropdown (no shell.routes, no target.kind — those are not part of this engine; see the wattyzer routing-contract doc for the extended variant). One primary menu renders vertical in left (desktop) and icon-bar in bottom (mobile); each chapter demonstrates one secondary layout.

NAVIGATION TREE  (app_config.json)                ROUTE INDEX  (runtime, flat)
─────────────────────────────────────            ──────────────────────────────────────────
toolbar (zone:top)                                path            target / effect
  burger ─── drawer toggle menu_id:quick          ──────────────  ────────────────────────────
  brand ──── navigate /tabs                       /tabs           ∅  → redirect to /tabs/a
  lang ───── event EV_TOGGLE_LANGUAGE (*)         /tabs/a         C_TEST_VIEW @main keep_alive
  theme ──── event EV_TOGGLE_THEME    (*)         /tabs/b         C_TEST_VIEW @main keep_alive
  user ───── dropdown { navigate, event }
                                                  /tabs/c         C_TEST_VIEW @main lazy_destroy
menu.primary (left:vertical / bottom:icon-bar)    /submenu        ∅  → redirect to /submenu/profile
  "tabs"      /tabs      (container)               /submenu/profile  C_TEST_VIEW @main keep_alive
   └─ submenu render {top-sub: tabs}               /submenu/sessions C_TEST_VIEW @main keep_alive
      ├─ "a"  /tabs/a    ├─ "b" /tabs/b            /submenu/tokens   C_TEST_VIEW @main keep_alive
      └─ "c"  /tabs/c                              /cards          C_YUI_NAV @main (cards landing)
  "submenu"   /submenu   (container)               /cards/alpha …  C_TEST_VIEW @main keep_alive
   └─ submenu render {right: submenu}             /accordion      C_TEST_VIEW @main keep_alive
      header/divider + profile/sessions/tokens                     (embeds a live accordion nav)
  "cards"     /cards     (index landing)          /form           C_DEMO_FORM @main keep_alive
   └─ submenu {render:{top-sub:tabs}, index:true}  /table          C_DEMO_TABLE @main keep_alive
      └─ alpha/beta/gamma/delta
  "accordion" /accordion ─── target inline        (*) toolbar action:"event" → publishes the
  "form"      /form      ─── target C_DEMO_FORM        event (theme toggle), NO route, NO index
  "table"     /table     ─── target C_DEMO_TABLE
                                                  menu.quick (drawer overlay, burger)
                                                   q-* → reuse the primary-menu route entries
                                                   (no own target)

What this example demonstrates that the generic §2 picture only states:


10. The legacy stack (C_YUI_MAIN / C_YUI_ROUTING) — removed in 3.0.0

The legacy stack coexisted with the shell while consumers migrated (this section used to document the coexistence and drift policy). As of 3.0.0 the migration is complete and c_yui_main.js/.css, c_yui_routing.js/.css, c_yui_tabs.js, themes.js and ytable.js/.css are removed from this line. The frozen v1 npm line (@yuneta/gobj-ui@^1.x, dist-tag legacy) still ships them for estadodelaire/hidraulia.

What replaced each piece on main:

Implemented ✓ in this pass

Migration track — closed

The retirement plan lived in TODO.md §1 (inventory → consumer migration → removal); it completed with the 3.0.0 removal above.


11. Escape priority chain

The shell maintains a single, ordered stack of close handlers, one entry per open overlay (drawer today, modal/popup when #4 lands, custom app overlays via the public API). Escape calls the top entry only and consumes the event (preventDefault + stopPropagation), so a modal opened over a drawer closes first; the second Escape closes the drawer.

LIFO ordering naturally matches the z-index layering most apps use:

loading       (no Escape — full-screen blocking spinner)
modal         (z-index 99)
popup         (z-index 20)
overlay       (z-index 15)  ← drawer lives here
base          (z-index  1)  ← never on the Escape stack

The drawer integration is built in: yui_shell_open_drawer / toggle_drawer push their close handler; yui_shell_close_drawer, the backdrop click, and Escape itself all funnel through the same close path (close_drawer_one) which removes is-active, releases the focus-trap, and pops the stack entry.

The backdrop click is routed via EV_DRAWER_CLOSE_REQUESTED from the nav to the shell, not by mutating the DOM directly — that guarantees the focus-trap and stack stay in sync regardless of which path closed the drawer.

Public API (for custom overlays)

import { yui_shell_push_escape, yui_shell_pop_escape } from "@yuneta/gobj-ui";

let close_modal = () => my_modal.close();
yui_shell_push_escape(shell, "modal", close_modal);
//   ... when the modal closes by any path (Escape, overlay click,
//   programmatic close, ...) the same handler must also pop:
yui_shell_pop_escape(shell, close_modal);

layer is a free-form tag (e.g. "modal", "popup", "overlay"). It is informational today (the LIFO ordering is what drives priority); keep it accurate so the FSM trace is readable.