npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@yuneta/gobj-ui

v7.27.34

Published

Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed from the npm registry by the Yuneta SPAs (gui_agent, gui_treedb, wattyzer, yunovatios, yunomusica, estadodelai

Readme

gobj-ui — Yuneta UI Library

Reusable GUI components for Yuneta GClass front-ends: a declarative shell (C_YUI_SHELL/NAV/PAGER/WIZARD), floating windows (C_YUI_WINDOW/WINDOW_MANAGER), TreeDB editors, charts and maps. The legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) was removed from this line in 3.0.0 — the frozen v1 npm line still ships it.

Published as @yuneta/gobj-ui. Built on top of @yuneta/gobj-js.

Routing & navigation: every navigable state is a URL. Before adding any view or navigable element, read ROUTING.md — the shell's routing contract (URL = source of truth, push/replace history, the position/preference/transient litmus).

Peer floors today (package.json): @yuneta/gobj-js ^7.26.3, @antv/g6 ^5.1.0, bulma ^1.0.4, i18next ^26.3.6, maplibre-gl ^6.11.2, tabulator-tables ^6.5.3, tom-select ^2.6.2, uplot ^1.6.32, vanilla-jsoneditor ^3.13.0. The notices below say when and why each floor moved; the list above is what a consumer must meet now.

BREAKING (7.26.10): the peer floor of @yuneta/gobj-js rose to ^7.26.3. The library passes its markup through html_markup(), which first ships in that gobj-js. A consumer locked on an older gobj-js gets ERESOLVE: raise both in the same change. See the CHANGELOG entry.

BREAKING (7.23.172): the peer floor of @yuneta/gobj-js rose to ^7.22.0. The Developer window turns the runtime's trace bits on and off and never reads a message to decide what to show; it needs the trace API of that gobj-js. See the CHANGELOG entry.

BREAKING (7.0.0): a dependency-only major — no component API moved. The maplibre-gl peer floor rises to ^6.4.1, which is where DOM.sanitize stops leaving dangerous attributes behind when several sit next to each other. Raise the range in every consumer that declares maplibre.

BREAKING (5.0.0): a dependency-only major — no component API moved. The peer floors moved to maplibre-gl ^6.0.0 (see 7.0.0 above), @yuneta/gobj-js ^7.8.7, i18next ^26.3.6, tom-select ^2.6.2, vanilla-jsoneditor ^3.13.0. maplibre v6 is ESM-only with no default export, so a consumer imports it as import * as maplibregl; bundling the map with Vite 8 also means emitting maplibre's worker + shared chunk yourself and pointing setWorkerUrl() at them (Vite cannot statically follow v6's dynamic worker URL, and a .mjs worker is refused when the host serves it as application/octet-stream). The test-app's vite.config.js (maplibre_worker_assets) + src/main.js are the reference wiring.

BREAKING (4.0.0): yui_shell_navigate(shell, route) now pushes a history entry by default; pass {replace:true} for a redirect / normalization / F5-restore — anything code decided rather than the user. It used to replace unless given {push:true} (still accepted, now redundant). A call left unmigrated only leaves a spurious Back entry; the default is the failure-tolerant direction, since a forgotten {push} silently broke Back. See ROUTING.md §7/§9.1.

BREAKING (4.0.0): the legacy __yui_main__ theme/resize service is gone from v2. Components are self-contained: the theme lives in <html data-theme> and gclasses follow it through src/yui_theme.js (yui_theme_now() / yui_is_dark() / yui_watch_theme(gobj), all barrel-exported — the watcher translates the DOM mutation and the OS prefers-color-scheme flip into EV_THEME); reflow uses each component's own ResizeObserver. An app that registered a __yui_main__ service for gobj-ui's benefit can delete it; do not re-add one.

BREAKING (4.0.0): a window/modal title is now an i18n KEY, rendered with data-i18n so it re-translates on language change. Pass the key, never t(key), and never compose data into it — the DATA half (a topic/service/marker name) travels in the new title_prefix attr/opt, shown before the title and never translated (C_YUI_WINDOW, yui_shell_show_modal, the dock chip). The old title_fn/retitle_modal hooks are removed.

BREAKING (4.0.0): minimize requires a window manager. C_YUI_WINDOW paints its minimize button only when the window has a manager (C_YUI_WINDOW_MANAGER): minimize means "send to the dock", and without a manager there is nowhere to send it — so showMin is now ignored when there is no manager, and a manager-less window shows only maximize/restore + close. The self-contained "shade" fallback (roll up to the title bar in place) and its is-shaded CSS are removed; an app that relied on shading needs to register a manager.

Where a window lives (7.26.31, 7.26.35): a C_YUI_WINDOW whose host names no $parent mounts in the window layer of the shell (yui_shell_window_layer()), a stacking context of its own inside the overlay layer -- above the content, below the drawer, the toolbar's dropdowns, the dialogs and the toasts -- and on document.body only while there is no shell. It used to be the body always, and since .yui-shell is position: fixed (a stacking context of its own) a window at z-index 11 covered the WHOLE shell, modal layer included: the confirmation of a window's own close opened under it, out of reach. A $parent the host names still wins.

Every window of the library lives there, in ONE z-order (7.26.35). The site map, the raw json of the treedb graph and topics, the gclass viewer and the json of a treedb topic form used to name the shell's POPUP layer: above the drawer, in the way of the toolbar's dropdowns (which opened behind them where they met). And there were two orders: the dock raised its windows to a counter of its own, a window without a dock sat at 3 -- so a window opened later without a dock opened UNDER any docked one, and no click brought it up. Now one number orders every window (yui_shell_raise_window()): a window opens on top, a press brings it up (a docked one through the dock's EV_FOCUS_WINDOW, one without a dock through its own EV_SHOW), and nothing a window takes can reach the drawer or the layers above.

The place follows the shell. A window parented to the app (the developer window, a device window) outlives a logout that destroys the shell: it goes to the body with it, and the next shell takes it in again. Each shell built or destroyed sends EV_SHELL_CHANGED to every gobj that asked with yui_shell_watch_changes() -- every C_YUI_WINDOW and the dock do -- and each places itself again in its own action. A window its host placed stays where it was put, unless that element was inside the shell that went. From 7.26.31 to 7.26.34 such a window stayed in the destroyed shell's layer: alive, detached, invisible, and the first click on its menu entry closed the invisible one. Its Back entry goes with the shell that held it: after a re-login Back no longer closes it, its ✕ does.

A gclass viewer goes where the window it was opened from goes. Opened from a window that hangs where the shell puts every window -- its window layer, the body while there is none -- it takes that place as its opener does, and follows the shell with it. Up to 7.26.41 it was given that element by name, as a $parent, which is a choice of its host: opened from the developer window between a logout and the next login, it stayed in the body after the login, above the new shell's dialogs. Opened from a window its host placed elsewhere, it hangs beside that window, there for good, as before.

With two shells alive (no app has two), destroying the LAST one gives its windows to the one that remains: yui_shell_of() falls back to the last shell still alive. Up to 7.26.41 the fallback went empty, and they went to the body, above the dialogs of the shell that remained.

The dock (C_YUI_WINDOW_MANAGER, floating) lives in the shell's popup layer, at the top of it: above every window, below the dialogs and the toasts. It was document.body at z-index 1000000, above the whole shell -- clickable over a dialog's backdrop. Inline, it moves to the next shell's zone after a logout, and floats in the body between them. A chip of the dock is no control itself (7.26.35): it holds two sibling buttons, the toggle (minimise / bring back, named by the window's title, through the FSM: EV_TOGGLE_WINDOW) and its ✕ (close). It was a role="button" holding the ✕ button -- one control inside another.

A floating dock keeps its band free (7.26.43). A window that fills the screen -- the sheet a window becomes on a phone, a maximized one on a laptop -- went under the floating dock: at a bottom corner the dock covered its foot, at a top corner its ✕ and its minimize. It happens on a phone to every app whose dock is responsive with no inline_selector (it floats there too). The dock now says the band it keeps, on the root element (--yui-dock-inset-top / --yui-dock-inset-bottom, set while a floating dock shows a chip), and a full-screen window takes top: var(--yui-dock-inset-top, 0px) and a height that leaves both bands out. A maximized window is therefore that much shorter with a floating dock; an inline dock keeps no band (it is in the layout).

A window is a dialog (7.26.43). focus, modal and keyboard were declared and did nothing up to 7.26.42: a window took no focus, closed with no key, kept nothing in and had no role. Now:

  • it is role="dialog", named by its title (aria-labelledby on the two halves, title_prefix and title, so the name changes language; under a header of the caller's the title is still there, is-sr-only);
  • when it starts (gobj_start, so a host that fills the body after creating it can name what it put there) in answer to the reader -- a press or a key, the browser's user activation -- it takes the focus: focus is an element of the window, a selector looked up in it, or a number (the n-th control of the body); by default the window itself (tabindex="-1"). A window the app opens on its own (the Developer window restored at start up from the last session) leaves the focus in the page, or a keyboard reader's first Tab lands in a window they did not open; a modal one takes it always. Nor does a window opened under an overlay that holds the focus -- a modal window, a dialog, the drawer, a toolbar menu, all drawn above every window that is not modal (7.26.45): it took the focus where it could not be seen, and the next Tab gave it back to the overlay. It opens under it, and is there when the overlay goes. A window placed by its host INSIDE that overlay ($parent in a modal's body) takes it as before. When it goes, the focus goes back where it was -- if it was in the window;
  • Escape closes it (keyboard, true by default) when the focus is in it, the way its ✕ does: EV_CLOSE_WINDOW, so EV_WINDOW_TO_CLOSE still vetoes or asks. Not an Escape that what it was typed in took first: a handler that called preventDefault() (a Tom Select list, the json editor's search box), an input method composing, an inline editor that closed with the key (Tabulator cancels a cell edit by removing the box -- the window then takes the focus back), a textarea or a contenteditable (what is being written is not lost to one key). Nor while an overlay of the shell opened AFTER the window is up (a toolbar menu, 7.26.45): the key goes on to the shell, which closes that overlay first (yui_shell_escape_order()). Turn it off with keyboard: false; the key then goes on to the shell;
  • a modal one blocks the rest of the app: it hangs over a backdrop in the shell's dialog layer (yui_shell_modal_layer(); above the drawer, the dock and every other window, below the toasts, and a question it asks opens above it), aria-modal="true", a press on the backdrop does nothing, and Tab stays inside (the trap of the shell's dialogs). It never goes to the dock: a manager given to a modal window is ignored, with a warning.
const win = gobj_create_service("keys", "C_YUI_WINDOW", {
    title_prefix: topic,            // data, never translated
    title:        "keys",           // the key
    body:         $box,
    focus:        ".KEYS_FILTER",   // the filter box takes the focus
    modal:        true,             // answer it before anything else
}, gobj);
gobj_start(win);                    // opened by the reader: the focus moves here

The shell's Escape follows the same rule (7.26.43). It closed the overlay on top of its chain for any Escape: one typed in a table cell being edited inside a dialog cancelled the edit and closed the dialog with it. The rule is one function, escape_is_taken() (src/escape_taken.js). An Escape that closed a cell editor took the focus with it, to <body>: the dialog on top takes it back (7.26.45), as a window does.

The focus trap counts only what is drawn (7.26.43). The adaptive dialog's back arrow is the first control of every dialog and is display:none on a laptop: the trap gave it the focus on opening (so the focus stayed outside the dialog), and every Tab after that "wrapped" to it, did nothing and was prevented -- the keyboard never got into a dialog. It now skips a control that is not drawn.

A shell that stops closes what it has open (7.26.46). A logout, or a session cut, with the drawer, a modal or a question up: the shell closes each one, the top first, as if it had been dismissed -- a modal's on_close runs (its before_close is not asked: that guard is for the reader), a question answers what Escape answers. Up to 7.26.45 only the toolbar dropdown was closed; the others kept their focus trap, and every window opened afterwards believed itself under an overlay and took no focus. An overlay of the app that uses the shell's Escape chain passes its unconditional close as the fourth argument, or it is left to its owner:

yui_shell_push_escape(shell, "modal", ask_then_close, close_now);

And it moves no browser history (7.26.47); the NEXT shell takes off what it left (7.26.49). The markers of the overlays it closes, and the action entry of a stay route parked under one, are dead once it stops: a Back onto one closes nothing. The stopped shell does not take them off -- a history.back() is asynchronous, and a stopped shell no longer hears where it lands (in 7.26.46 a logout followed by a new shell in the same turn had it take the new shell's first marker in Chromium, and the next Back left the app) --; it hands them over, and the next shell that starts with use_hash takes them off with ONE history.go(-n) heard by its own landing bookkeeping: an overlay it opens meanwhile gets its marker after the landing, a route it pushes runs after it, and the position the URL holds is put back where it lands. In 7.26.47 and 7.26.48 they stayed, and every logout with an overlay up left more dead Backs (two per cycle with a question over a panel). The walk stops at the first entry of the reader's, and at the first entry of the current DOCUMENT (7.26.51): a location.replace() to another page keeps the key of the entry it replaces, so a later load of the app can sit on the key of a dead marker, over the entries of the load before it, and 7.26.49 went back into them -- that load was loaded again. After a reload every entry is the new load's (sameDocument, Chromium and Firefox), and its dead entries go. Dead entries UNDER an entry of the reader's (the reader moved while the login page was up) are taken off when a Back of the reader's lands on them: the shell goes on over them, down to the first live entry, with one history.go(-n); a Forward onto them goes on up to the first live entry over them, or stays where it is when there is none (7.26.51; in 7.26.49 and 7.26.50 they were never taken off: two dead Backs with a question over a panel). The direction comes from the Navigation API's currententrychange. So a shell may move the history by itself, at its start and after a Back or Forward of the reader's (7.26.49 and 7.26.51): an app with its own popstate / hashchange listener hears that traversal. The entries are known by their Navigation API key (Chromium, Firefox and Safari 26.2+). Without the API they stay, as in 7.26.48: each logout with a question over a panel adds two entries and two dead Backs, and each logout with the preferences up on their action route (/prefs, stay) leaves the action entry under their marker -- yui_shell_unpark_route() rewrites only the entry the URL is on --, so a Back over it opens the preferences again, once per such logout (three logouts: three times; measured in Chromium and Firefox with the API taken away). A page with no next shell keeps them -- a login page that stays while the reader presses Back: two dead Backs with a question over a panel, which change nothing on screen since no router is on the page --, and the next shell takes them off. A reload keeps them in the tab's sessionStorage for the first shell of the new load (a tab opened from this one starts with a copy, and a key that names none of its entries is dropped). A history.back() of the stopped shell still in flight (an overlay closed by its own control in the same turn as the stop) is adopted by a shell started before it lands. An overlay parked on an action route (redirect: "stay") whose on_close calls yui_shell_unpark_route() has the URL rewritten in place to the resting route, not taken back (7.26.48): a single back() landed on the action entry under the marker left in place, and the next shell opened the overlay again. An app that unparks with its OWN history.back() has the same defect: call yui_shell_unpark_route() instead. A question open at the stop answers what Escape answers -- false for a yes/no or a danger question, "cancel" for yes/no/cancel --; a yui_shell_confirm_ok() resolves as on every close, so code awaiting it runs after the stop and must not take the shell for alive.

A trap whose panel is out of the document is not the top: the top is the highest trap whose panel is in it, so a trap left behind by an owner torn down with it no longer stands for an overlay that is gone. It is skipped, not dropped: it leaves the stack only by its release(), and a panel that only moves (taken out and put back) has its trap again (7.26.47; 7.26.46 dropped it for good). The first time a trap is skipped it is said, once, with the panel's classes (7.26.49): a panel whose owner is gone keeps its keydown listener until a release() that will never come, and the warning is how that owner is found.

Two maintained lines

This repository carries two parallel lines with different layouts and consumers. They are independent snapshots (no shared git ancestry):

| Line | Branch | Latest tag | Layout | Consumed by | How | Status | |------|--------|------------|--------|-------------|-----|--------| | v2 | main | 7.26.x | src/ subdir | gui_agent, gui_treedb, wattyzer, the yunovatios GUIs, yunomusica, estadodelaire (since 2026-10-13), hidraulia (since 2026-10-15) | published npm @yuneta/gobj-ui (dist-tag latest) | active development | | v1 | v1 | 1.0.4 | src/ subdir | nobody since 2026-10-15 (hidraulia was the last) | published npm @yuneta/gobj-ui@^1.0.4 (dist-tag legacy) | frozen, maintenance-only |

  • v2 / main is the active development line: the declarative shell (legacy-stack-free since 3.0.0). It is embedded as a git submodule in yunetas at kernel/js/gobj-ui, and every app takes it from the npm registry (since 2026-08-03 there is no file: consumer left). Apps import by package specifier (@yuneta/gobj-ui/src/*.js, exports map "./src/*"; the index.js barrel and the vite plugin stay at the package root), and the published tarball ships src/, index.js and the vite plugin. So library work reaches an app only after a npm publish and a range bump on its side (npm run deploy-round does the round). test-app/ is the one exception: it consumes the working tree (file:..), which is what it is for.
  • v1 / v1 is the frozen legacy-only stack (the declarative shell is not on this line). It is published to npm (@yuneta/gobj-ui@^1.0.4), and no app consumes it any more: estadodelaire left it for v2 on 2026-10-13, and hidraulia, the last one, on 2026-10-15. Land only maintenance fixes here, then npm publish a new 1.x.

All new feature work lands on main/v2.

Usage

# v2 (active): every app installs the published package
npm install @yuneta/gobj-ui@latest

# working on the library itself: clone yunetas with submodules
git clone --recurse-submodules <yunetas>
git submodule update --init kernel/js/gobj-ui      # yunetas tracks main/v2

# v1 (frozen): consumers install the legacy line
npm install @yuneta/gobj-ui@legacy

Edit v2 from the yunetas kernel/js/gobj-ui checkout, commit on main in this repo, then bump that submodule pointer in yunetas. For v1, work from a v1 checkout and publish.

Build & test

npm install
npm run build      # vite -> dist/ (ES/CJS/UMD/IIFE, min + non-min)
npm test           # vitest (v2/main only; v1 has no test target)
npm run qa-history # the shell's browser history in REAL Chromium and Firefox

npm run qa-history (scripts/qa-history.mjs) starts the test-app's vite dev server and drives its /history chapter: a panel with no dock and keep_on_navigate over a view that writes its position with a replace. Back must close the panel and keep the position, the panel's x must keep it, and a dialog action that closes the panel and navigates in the same turn must keep the navigation. The unit tests drive a MODEL of the history; this is the check in the browsers themselves. Run it after any change to the overlay history of C_YUI_SHELL. Playwright and its browsers come from the test-app.

dist/ is gitignored. v1 consumers get dist/ from the published npm tarball; v2 consumers import source files by specifier, whether they resolve them from the checkout or from the tarball's src/. Rebuild dist/ to validate and before publishing a release.

Components

Site map — yui_shell_show_route_map

Every declarative-shell app can render its WHOLE navigation surface — toolbar + account menu + every declared menu + live dynamic tabs + each view's contributed sub-routes + the routes declared only in the route table — as a printable, filterable, clickable tree (a floating C_YUI_WINDOW, modal fallback) that doubles as the app's basic documentation. The current route is marked "you are here". Wire it from an account-menu entry (type:"event" → EV_OPEN_SITEMAP, or a deep-linkable /sitemap action route with redirect:"back") and call yui_shell_show_route_map(shell, {t}) from the handler; a second call toggles it closed. The tree model is yui_shell_nav_map() / route_map_model.js (pure, unit-tested). Semantics and the contributor protocols (yui_shell_set_sub_routes, yui_shell_register_event_handler) live in ROUTING.md.

A row says the name its control says -- one rule names an item everywhere. A menu entry (a submenu entry, a runtime tab) is labelled as the nav names it: its name, else its aria_label, else its id. A toolbar item and a row of its dropdown as the toolbar names them: aria_label first, then name, then the library's own key for an avatar (user menu) or a connection dot (backend connection), else the id. All of them are KEYS, and change language with the map. Two labels are not: the id of another menu (a menu declares no name, so its group row shows its id as DATA) and a route (a row that no item names has no label; its route is in its own column, and is its link's name as data). The root row is the brand's wordmark: it names the APP, where the brand button's aria_label says what pressing it does. Up to 7.26.41 a row was labelled name || wordmark || id || route.

C_YUI_NODE — navigation as a tree of gobjs (prototype)

C_YUI_SHELL's menu tree is two levels (a primary item and its submenu.items); a submenu item cannot declare a submenu of its own, so a section with sub-sections has to flatten everything into one tab strip.

C_YUI_NODE is the prototype of the other model: the gobj tree IS the navigation tree. A node is a gobj, the URL is the path of node ids under a single declared base_route, and a parent holds how it wants its children seen:

{
    "gclass": "C_YUI_NODE",
    "kw": {
        "node_id": "cards", "base_route": "/cards",
        "projection": {
            "index":  {"layout": "cards"},
            "chrome": [{"layout": "tabs", "show_on": ">=tablet"},
                       {"layout": "backbar", "show_on": "<tablet"}]
        },
        "content":  {"gclass": "C_MY_LANDING", "kw": {}},
        "children": [
            {"id": "energy", "label": "Energy", "icon": "yi-bolt",
             "projection": {
                 "index":  {"layout": "cards"},
                 "chrome": [{"layout": "tabs", "show_on": ">=tablet"},
                            {"layout": "backbar", "show_on": "<tablet"}]
             },
             "children": [ /* … any depth … */ ]}
        ]
    }
}
  • projection is a C_YUI_NAV render config (so cards/tabs/vertical/ icon-bar/backbar and show_on all work unchanged) in two modes: index when the node is the tip of the path — the projection IS the page — and chrome when a child is showing — the projection is the strip around it.

  • Chrome belongs to the node that declares it — so every branch declares its own. A node's chrome strip lists that node's children, and its backbar goes back to that node's route (back_route = my_route). A branch that declares no chrome therefore contributes no strip, and the only ← the user can reach is the nearest ancestor that did declare one. Declaring the pair only at the root is the mistake this rule exists to name: the whole subtree then shows one "← root" that says the same thing at every depth, instead of one ← per level going up exactly one level. Repeat the same chrome on every branch that can have a tip below it — that is what test-app's /cards does, and what makes its ← hierarchical.

  • content and children are not exclusive: a section with its own page and sub-pages is one node.

  • The route table does not grow. The host declares ONE route; everything below arrives as the shell's subpath (ROUTING.md §4), and the tree contributes its full shape to the site map via yui_shell_set_sub_routes.

  • Two ways to show depth. Stacked chrome — one strip per ancestor — reads well at three levels and eats the screen at five. The other way is projection.path: the trail down to the user as ONE line ({"layout": "breadcrumb"}), drawn from the tree root whichever node declares it, each crumb a link to that level. Declared per branch, so a deep corner can trade its strips for a breadcrumb (chrome_depth: 0 + projection.path) while the rest of the tree keeps its tabs. Note the asymmetry that makes it a third mode and not a layout: index and chrome project a node's CHILDREN; path projects the way in.

  • remember_position — an item points at where you LEFT that child. Without it a nav item points at the canonical route of its child, so a strip of children behaves like a row of tabs that forgets: open a topic inside one, move to a sibling, come back, and the tab is at its landing — browser Back the only way to what was open. With it, the item carries the tail that was last active under that child.

    It stays a real position, which is the reason it is done here and not by the viewer restoring itself: clicking is a navigation like any other, nothing redirects and nothing argues with the url. A bare navigation to a child records "its home", so choosing the landing sticks too.

    Off by default: a tree whose children are pages wants the item to BE the destination. On for a tree whose children are workspaces with a position inside them — the agent console's strip of treedbs, each with its open topic. Since 6.2.0. Declarable in a child spec since 7.23.140 ("remember_position": true next to its id): the attr existed, but the spec never passed it on, so a node declared in a config could not turn it on. yunovatios turns it on for its treedb nodes, so the graph tab returns to <graph>/<topic> with its focus and the data tab to the topic that was open.

  • A node hears every event of the navs it projects (they are its pure children). Since 7.25.19 it declares all three, in every state -- a ZONE projection outlives the path, so a root that is off it still hears its rail: EV_NAV_CLICKED (a navigation), EV_NAV_ITEM_CLOSE (handed to the shell, which re-publishes it to the app, as for the shell's own closable items) and EV_DRAWER_CLOSE_REQUESTED (the node closes its own drawer projection: the shell's yui_shell_close_drawer() knows only the shell's navs). Before, the last two answered "Event NOT DEFINED in state", and so did a click on a zone nav of a root in ST_OFF.

  • nav_mode — the three shapes as one runtime knob. The two bullets above describe what a tree declares; nav_mode is how a user chooses between the shapes without the app rewriting anything:

    | mode | what it shows | equivalent declaration | |---|---|---| | "stack" (default) | one strip per ancestor | whatever each branch declares | | "back" | only the tip's parent, as a ← parent | chrome: {"layout":"backbar"} everywhere + chrome_depth: 1 | | "path" | the trail as ONE line | path: {"layout":"breadcrumb"} on the root + chrome_depth: 0 |

    It belongs to the root (yui_node_set_nav_mode(root, "path"), or "nav_mode" in its declaration) and the whole tree reads it from there — ancestors stacking strips while a descendant drew a breadcrumb would be saying the same thing twice in two languages. Set on a middle node it is refused, loudly, rather than accepted and ignored.

    A mode filters the renders as they are asked for; it never rewrites what the app declared. That is what makes "stack" an exact restore: a branch that declared vertical chrome comes back as vertical, not as the tabs a canonical "stacked" shape would have imposed. The index projection is never touched by a mode — how a node shows its own children when it IS the page is not a statement about depth. Modes are per tree, so an app can run /admin as a breadcrumb and /alarms as a backbar; test-app's node lab cycles all three on the live tree.

  • chrome_depth caps the stacked chrome: with every ancestor painting its own strip, depth N shows N-1 of them. A node declares how many its corner of the tree deserves (0 = none, omit = all), the **deepest declaration on the path wins, and an intermediate node whose only job is to hold that number is a legitimate node.

  • Declarative and dynamic are the same code. The declared children attr is fed to the same EV_ADD_NODE the runtime API uses:

    yui_node_add(node, spec, index)      yui_node_remove(node, node_id)
    yui_node_set_projection(node, proj)  yui_node_set_content(node, content)
    yui_node_set_chrome_depth(node, n)   yui_node_tree_version(node)
    yui_node_set_nav_mode(root, mode)    yui_node_nav_mode(node)
    yui_node_find(node, "energy/north")  yui_node_route(node)

    A node added at runtime is deep-linkable like one declared at boot. Removing the branch the user is standing on moves them to the nearest living ancestor (replace, logged) — with a live tree the ground can disappear under a bookmark.

Every move goes through the URL: a projection click publishes EV_NAV_CLICKED, the node turns it into a push navigation, and the shell's EV_ROUTE_CHANGED walks back down the tree as EV_ACTIVATE. Back, Forward, F5 and deep links are therefore correct by construction.

The root can be a node too — config.shell.tree. Declared there, the shell stops owning the menu and keeps only the space (zones, layers, stages, toolbar, overlays, theme, breakpoints): the root node's children are the app's primary options, and it projects them into zones instead of into its own body.

"shell": {
    "zones": {"top": {"host": "toolbar"}, "left": {"show_on": ">=desktop"},
              "bottom": {"show_on": "<desktop"}, "center": {"host": "stage.main"}},
    "stages": {"main": {"zone": "center", "default_route": "/"}},
    "tree": {
        "base_route": "/", "stage": "main",
        "projection": {
            "index":  [{"zone": "left", "layout": "vertical"},
                       {"zone": "bottom", "layout": "icon-bar"}],
            "chrome": [{"zone": "left", "layout": "vertical"},
                       {"zone": "bottom", "layout": "icon-bar"}]
        },
        "children": [ /* the primary options, and everything under them */ ]
    }
}

Note what is NOT there: no zone declares host: "menu.<id>", and there is no menu block at all. A render config with a zone mounts through yui_shell_zone() and persists — the rail is standing chrome, so it is built once and told where the user is, not rebuilt per navigation. menu.primary.render always was a per-zone projection; this just gives it an owner that can hold it.

shell.tree synthesizes exactly ONE route entry, flagged owns_subtree, which is the only case where root / may match as an ancestor (route_resolver.js). The unknown-route diagnostic is not lost by that: it moves to the node that actually knows the names of its children. Runnable reference: test-app/tree.html (_qa_root.mjs), served beside index.html so the two navigation models can be compared in one browser.

Where the tree ends. One gobj per structural node is right; one gobj per meter reading is not. A node marks the boundary with link — a pointer into a data space (a timeranger: millions of raw records, series/time, key/value) plus the viewer suited to that shape:

{"id": "m1", "label": "Meter 1",
 "link": {"kind": "tranger", "gclass": "C_MY_TRANGER_VIEW",
          "kw": {"topic": "meters^north^m1"}}}

A link node is always the tip of the structure: the url keeps going, but its tail is handed to the viewer as EV_ROUTE_CHANGED {base, subpath} — the same contract the shell gives a view (ROUTING.md §5), so a viewer cannot tell whether the shell mounted it at a declared route or a node did, deep in a tree. base is the node's canonical route, which is what the viewer builds its own deep links from. An empty subpath means the viewer's home, which is what makes Back out of a deep data position land on it. Below a link there are no nodes: link + children (or link + content) is a config error, because a silent winner in "who owns the subpath" would be the worst outcome.

A child already on screen stays in the document (7.27.20+). Every change of url below a node re-activates it, and the node used to hang its active child again each time -- out of the document and back in, which a browser answers by dropping the scroll of every element inside it. A topic table under a treedb view came back at the top of a page whose rows were drawn for the bottom, and showed nothing. Now only the node's chrome (navs, path) is rebuilt when the active child is the one already hanging in its body.

A content shown again is told so (7.27.21+). A node keeps the content it built, as the shell keeps a keep_alive view, so a content that reads its data only when it is created declares EV_VIEW_SHOWN and reads it again there: the node sends it, with kw {route}, when it comes back to that content. That also covers the whole tree shown again by the shell, because every node on the path comes back with the shell's route change, so the node itself does not declare EV_VIEW_SHOWN and the shell does not tell the tree a second time (7.27.30+). Once per showing (SHELL.md §4).

The tree is a contract, not runtime state. Once published, a node's path is a url a client may have bookmarked, scripted, or been sold as another door into the system. So there is deliberately no reparent/move API: the shape is versioned (tree_version on the root, yui_node_tree_version()), and a rename migrates through aliases — the former id keeps resolving and the URL is rewritten (replace) to the canonical spelling, the same shape as an HTTP 301. Anything a version bump cannot cover is a new tree, declared as such.

Runnable reference: the Cards chapter of test-app (four levels plus a panel that mutates the live tree), driven by test-app/_qa_nodetree.mjs and test-app/_qa_extra.mjs.

C_YUI_SERVICE_VIEW — mounting a view that talks to a backend

A view asks the backend for data with gobj_command(remote, …, src = itself), and C_IEVENT_CLI routes the answer back with gobj_find_service(gobj_name(src)) — which only finds registered services. Neither host creates one, so a backend-talking view mounted directly at a route never receives a single answer: it sits empty while the ievent logs "service not found" once per answer. And a route's target.kw is static JSON, so it cannot carry the live transport pointer either.

Two shapes, because the callers are not alike:

/*  A route with NO extras: declare the host, name the view it hosts.  */
{ gclass: "C_YUI_SERVICE_VIEW", kw: {
    view_gclass:  "C_MY_VIEW",
    service_name: "#my-view",          // UNIQUE per mount — see below
    view_kw:      { title: "…" }
}}

/*  A wrapper that keeps its own extras (url segments into the hosted view,
 *  rebinding it when a connection drops): drop only the boilerplate.       */
let view = yui_mount_service_view(gobj, {
    gclass:    "C_YUI_TREEDB_TOPICS",
    name:      service_name(gobj),
    kw:        {...},
    transport: remote            // already resolved (e.g. per connection);
});                              // omit it for "__remote_service__"

The hosted gclass must declare the attr the transport is injected under (gobj_remote_yuno by default), build its $container in mt_create, and flag EVF_PUBLIC_EVENT on whatever arrives from the backend — the ievent drops events that are not public.

The service name must be unique per mount, and a duplicate is dangerous precisely because it is not fatal: gobj-js logs "service ALREADY REGISTERED. Will be UPDATED" and rebinds the name, so two mounts of one route would cross their answers. Derive it from the route (or from whatever else makes the mount unique — a connection id, a workspace), never from the gclass alone.

Why the hosts do not just create services. It would make every routed view an inter-yuno endpoint by default, against the framework's rule that only named services are; most views never talk to a backend; and the collision above would become the default failure mode. Opt-in per route instead.

Known asymmetry, deliberately left alone: C_YUI_SHELL mounts a view with gobj_create() (a plain child) and C_YUI_NODE with gobj_create_pure_child(). The flag decides whether a gclass that consults gobj_is_pure_child() sends its output event straight to the parent or publishes it. Today nothing consults it on a view (only c_ievent_cli and c_timer do, and always about themselves), so the difference has no observed consequence — but the same view gclass does get a different flag depending on who mounted it. Align it the day it bites, with the case that bit.

C_YUI_JSON — lazy JSON tree viewer

Indentation follows the house rule: four characters per level, plus a guide line per ancestor. The rows are siblings with growing padding rather than nested boxes, so the guides are painted as a repeating gradient bounded to each row's own indentation (background-size set per row) — which is why the hover state must set background-color, never the background shorthand, or the guides vanish under the cursor.

A container-agnostic viewer (like C_YUI_PAGER): it owns only a toolbar + scrollable tree body and exposes a $container the parent mounts wherever it wants (a C_YUI_WINDOW body, a yui_shell_show_modal card, or inline). It is built to show arbitrarily large JSON, so it never assumes the whole document fits in memory or the DOM.

Server-driven lazy expansion. The C kernel's kw_collapse() (kwid.c, used by the print-tranger command) truncates over-limit dicts/arrays into a sentinel — { "__collapsed__": { "path": …, "size": N } } (dict) or [ { "__collapsed__": … } ] (array). C_YUI_JSON renders each sentinel as an expandable stub and, when the user opens it, does not fetch anything itself: it publishes EV_EXPAND_PATH {path, size} to its subscriber. The subscriber is the only party that knows the backend (it re-issues print-tranger path=<path> with limits, or any equivalent), and hands the subtree back via EV_SUBTREE_LOADED {path, json}. Only expanded containers are materialised in the DOM, so the tree stays bounded regardless of document size. With no sentinels present it degrades to a plain client-side collapsible tree.

Three views, one document. The toolbar switch (and the view_mode attr, "tree" | "text" | "graph") picks which. They answer three different questions:

The switch reads text · tree · graph, flattest reading first — which is not the order of arrival.

Which view it opens on is decided in three steps: what the HOST asked for (the view_mode attr), then what the READER chose last time, then the tree. That is why the attr's default is the empty string and not "tree": as a default and as a host's explicit choice "tree" was the same string, so nothing could tell "show me the tree" from "I have no opinion", and a memory that cannot see the difference has to lose to both. Pass view_mode only to PIN a view.

The reader's choice is kept in localStorage under one key for the whole library — which of the three views somebody reads JSON in is a habit of the person, not a property of the document — and only a view they picked is remembered: a mode the host pinned, or the tree we fell back to, would otherwise be written back as if somebody had chosen it.

| view | question | notes | |---|---|---| | text | what does this document say, verbatim | JSON.stringify(…, 4), four characters per level | | tree | where is this value, and what is around it | the lazy view; the only one that can drill; the default | | graph | what shape is this | a hosted C_YUI_JSON_GRAPH child (AntV/G6) |

What the graph draws is the document, not a rearrangement of it. Every key is a ROW of its card, containers included — cols is one key of a topic dict exactly like pkey is, and a drawing that leaves it out does not say what the document says. What a container row does NOT get is its contents repeated underneath it: the row says [14] and the fourteen cards say the rest, which is the part that has to scale.

A container with no scalars of its own gets no card at all: it is not a thing, it is a LIST of things, described by the row that names it plus the edges to what it holds, and its children hang from its parent. The root always keeps a card.

Each container key opens a G6 port on the line of its own row, and its edge leaves from there; the edge carries an arrowhead and arrives at an in port centred above the target card's title. Ports on an html node need care: the HTML is a DOM layer over the canvas, so a port fully inside the box is painted under the card — they sit ON the border.

Cards are JSON_CARD and carry data-json-path (GOBJ_CARD / data-gobj-name in the gobj tree, TREEDB_CARD in the treedb graph): a gclass that builds DOM owes the Inspector a readable tree, and the anchor's mark needs something to find its card by after a rebuild — the node id does not survive one, the path does.

Since 7.23.2 the schema and cell-JSON popups of C_YUI_TREEDB_TOPIC_WITH_FORM present that viewer as a floating C_YUI_WINDOW on a laptop — movable, resizable, maximisable, position remembered — and keep the modal sheet on a phone. A JSON document is read while looking at the table it came from, which a modal cannot allow; a phone has no room to arrange anything, so the sheet stays. Note the close contract: C_YUI_WINDOW.close_window() calls on_close and THEN destroys itself, so a host must drop its reference rather than destroy the window too.

Its camera comes from yui_graph_camera.js (7.23.4), the one place every DOM-toolbar graph asks for one — this gclass and the gobj tree both, so a cloned toolbar cannot drift apart again. The vocabulary is the treedb graph's (c_g6_nodes_tree.js, which draws its toolbar as a G6 plugin over an SVG sprite and so cannot share the code, only the decisions): zoom in / zoom out, a live zoom readout, yi-fit for fit — the same corner brackets the sprite draws for g6-icon-fit — and the WRITTEN 1:1 for actual size, which is written in every editor that offers it and never drawn. Fold is the double-chevron pair (yi-angles-down / yi-angles-up, 7.23.147), the same as the lazy tree viewer's toolbar: not the single chevron of a node's handle, which opens ONE node. The readout hangs off G6's aftertransform, the one hook that also covers the wheel.

Both graph toolbars are arranged the same way (7.23.7): the global expand/collapse leftmost, ahead of the find box; camera and refresh in the centre; the layout picker on the right. The per-node fold is a different control and lives elsewhere — on the right of each card's own header, last, where the gobj tree puts its +N / −.

The graph carries its own toolbar, a layout picker (EV_CHANGE_LAYOUT {layout}: vertical tree, dagre top-down, dagre left-right — the layout and the edge type move together), and since 7.22.0 the same two facilities the tree has: a find box (rate-limited, EV_FIND_NODES) that highlights matching rows and outlines the cards they are in and says how many matched, and expand-all / collapse-all (EV_EXPAND_ALL / EV_COLLAPSE_ALL) that fold every card but the root, marking each cut with ▸ N so the shape stays legible and you can see where the rest went. Since 7.23.0 each card that HAS a branch also carries its own handle in its header (EV_TOGGLE_FOLD {path}); a leaf gets a spacer of the same width instead, because a handle that does nothing is worse than none and the labels should stay on one axis.

That handle is the same filled chip the gobj tree draws (+N folded, − open — see render_toggle_html in c_yui_gobj_tree_js.js), and it is a chip for a reason: 7.23.0 shipped it as a bare ▾ and on a phone it was invisible. At the zoom that fits a document on screen a glyph is a couple of pixels of ink; a filled chip is still a visible blob and reads as something you press. +N also carries the count of what is hidden.

The handle is delegated from the canvas mount in the capture phase, over pointerdown/pointerup/mousedown/click: the card is an innerHTML string so nothing can be bound to it directly, G6 binds on the node element below, and G6 builds its click from the POINTER sequence — swallowing click alone leaves the fold working and the card also reporting an item click. A find does not move the camera — a viewport that jumps on every keystroke is unusable — which is why the count is there.

Note where the highlight lives: baked into the card's own markup, never set as a G6 node state. The key shape of an html node is a DOM element and G6 paints no state style on it, so setting active selects correctly and shows nothing.

  • Neither text nor graph is lazy. Both show what the client currently holds, __collapsed__ sentinels included, because that is honestly what it has. Drill in the tree and they grow with it.
  • The tree-only controls (search, expand-loaded, collapse-all) hide with the tree; copy stays. A control that can answer nothing is worse than an absent one. The graph brings its own zoom/centre toolbar.
  • The graph child is built on first entry into graph mode, never in build_ui: G6 sizes itself from its container, so a graph created behind is-hidden comes up 0×0. register_c_yui_json() auto-registers C_YUI_JSON_GRAPH (and that register is idempotent, so an app may also register it itself, in either order).

Two layout facts the browser taught this component, both worth keeping:

  • In the text view long lines scroll sideways inside the viewer (white-space: pre on a max-content-wide <pre>), never on the page body: in a raw dump the indentation is the structure, and a wrapped line restarts at column 0 and lies about the depth of everything under it. The <pre> must be max-content wide or the container reports no overflow and the tail of every long line is unreachable.
  • The graph body carries a definite height (24rem, with flex: 1 1 auto so a constrained host still wins). Not min-height: a percentage height does not resolve against a box sized by a minimum, and the tree and the text push their own height while a canvas pushes none — so in an unconstrained host the graph came up as a 2px hairline.

Contract:

  • Attributes: subscriber, title (i18n key, optional — hidden on mobile, where the toolbar cannot hold it as well as the buttons), json_data (initial JSON, optional — given, the viewer starts in ST_READY), view_mode ("tree" default | "text" | "graph"), $container (mounted by the parent).

  • Input events: EV_SET_JSON {json} (replace the whole document; ST_EMPTY → ST_READY), EV_SUBTREE_LOADED {path, json} (splice a fetched subtree), EV_SUBTREE_ERROR {path, error | i18n, by_design}, EV_SET_VIEW_MODE {mode} ("tree" / "text" / "graph"; no mode advances to the next view, which is what the two-view toggle did when the list was two long), plus EV_REFRESH / EV_SHOW / EV_HIDE / EV_LANGUAGE_CHANGED.

  • Output event: EV_EXPAND_PATH {path, size} (EVF_OUTPUT_EVENT) — the parent must declare it in its own FSM (CHILD subscription model), and ANSWER it: the stub shows "loading" until EV_SUBTREE_LOADED or EV_SUBTREE_ERROR arrives. Every way the drill can fail is an answer: a request asked out of session, or refused by the transport on its way out, never reaches the backend, so no answer will ever come back for it -- the host answers it itself (C_YUI_TREEDB_TOPICS / C_YUI_TREEDB_GRAPH since 7.25.21: i18n: "no session", or the transport's refusal as error). So is a drill already SENT when the session drops: a transport answers nothing in flight on a close, so the host keeps the paths it asked and, on the disconnect edge (EV_TRANSPORT_STATE or the shell's EV_CONNECTION_STATE, both {connected: false}), answers each one i18n: "the connection dropped" (since 7.25.22); a failure that lands after that answer is dropped with a warning, not shown twice. While a path is pending the viewer ignores every click on it, so a drill left unanswered is a dead stub for the life of the viewer. A host that cannot read a subtree says so with the error, as C_YUI_TREEDB_TOPIC_WITH_FORM does for its schema, cell and table viewers since 7.25.19 (before, it declared nothing: "Event NOT DEFINED"):

    gobj_send_event(src, "EV_SUBTREE_ERROR", {
        path:      kw.path,
        i18n:      "this part cannot be loaded here",  // a KEY: t(key) + data-i18n
        by_design: true     // not a failure: logged as a warning
    }, gobj);

    error is text, drawn as it came (a backend's comment); i18n is a key, so the stub changes language with the app -- a text translated by the host stays in the language it was sent in. The name i18n is the one a validate-locales scan looks for, so the key stays visible to it. by_design says the host CANNOT, which is not a failure: the viewer logs it as a WARNING, and anything else as an ERROR. Before 7.25.20 the form sent t(...), and every click on a stub logged an ERROR, which broke a clean-console check and reached the backend through the remote log.

    EV_EXPAND_PATH is the ONLY output event, on purpose: this viewer is a child of its host and subscribes it to everything it publishes, so every output event is a mandatory declaration in every host's FSM. The graph child's EV_JSON_ITEM_CLICKED stops here (7.21.0 forwarded it and broke exactly that way); a host that wants node clicks mounts C_YUI_JSON_GRAPH itself.

  • Internal (DOM → FSM): EV_TOGGLE_NODE, EV_EXPAND_COLLAPSED, EV_SEARCH, EV_EXPAND_ALL, EV_COLLAPSE_ALL, EV_COPY_ALL. Every kw carries only a path string — never a DOM node or gobj.

  • i18n keys the switch needs: tree view, text view, graph view, text truncated; collapse some branches. All spelled out inside t() in view_label() — never t(VIEWS[i].key), which no validate-locales can see (that shipped once, in 7.20.0).

  • Paths use the kernel delimiter (backtick) and index arrays numerically, so a path emitted by the viewer round-trips through kw_find_path on the backend.

Backend note. The Raw JSON feed is print-tranger, which serves the tranger with both dict- and array-drill (via kw_collapse()): c_tranger.c for a C_TRANGER service, and C_NODE (it holds priv->tranger) for a treedb. A document that arrives with no __collapsed__ sentinels is simply rendered client-side (no lazy drill).

The reading of a time field. Beside a number under a time key (t, tm, __t__, from_t, fr_t, … — TIME_FIELDS in json_view_helpers.js, seconds or milliseconds) the tree writes the moment in the app's language with a clock of 24 hours (JSON_TIME):

"t": 1785312000  29 jul 2026, 10:00:00          (es, Europe/Madrid)
"t": 1785312000  Jul 29, 2026, 10:00:00         (en)

The viewer subscribes ITSELF to the shell's EV_LANGUAGE_CHANGED and draws the tree again (7.26.52). Up to 7.26.51 the reading was toLocaleString() -- the browser's language, and 12 hours in English -- and the language action never ran: no host forwarded the event. A language change draws only the tree: the text view carries its keys, and the graph is not loaded again (it would lose the reader's camera).

Logical DOM classes: JSON_VIEWER, JSON_TOOLBAR, JSON_SEARCH, JSON_TREE, JSON_ROW, JSON_KEY, JSON_VALUE, JSON_SUMMARY, JSON_COLLAPSED, JSON_TIME. The gclass imports its own c_yui_json.css.

What a node in the graph is CALLED

C_G6_NODES_TREE (the record graph inside C_YUI_TREEDB_GRAPH) labels a card by what NAMES the record, which is not always what KEYS it. A topic whose id column is flagged rowid, uuid or qualified keys its records by something that is not the plain name — a counter, a random string, or the name with every ancestor in front of it — and the name lives in the secondary key the topic declares (pkey2s). treedb_system_schema is the case that forced it: its topics and cols records are named in value, so the graph drew cards reading 181, 225, 193 while they were keyed by rowid, and would read the whole path now that they are qualified.

The rule is in treedb_node_label.js (pure, unit-tested): read the pkey column's flags from the desc; if the key is not the plain name, take the first pkey2s field the record actually carries; otherwise keep the id. The pkey is never lost — it is the card's tooltip, on the chip and on the entity card alike.

It needs the descriptor to carry pkey2s, which tranger2_topic_desc() only clones from SDK > 7.13.0. Against an older node the desc has no pkey2s, the label falls back to the id, and nothing else changes.

Which backend a view browses: source_url

C_YUI_TREEDB_TOPICS takes an optional source_url string and prints it in its toolbar, between the left buttons and the raw json one. A host passes the url of the connection the view reads through (wss://host:port), and an empty value renders nothing.

The tab that hosts the view is labelled with the treedb name, and a treedb name is not unique across backends: two tabs reading treedb_yuneta_agent are two different machines, and a wrong assumption there is a write on the wrong node. The url is what tells them apart, and it does not fit in a tab label — a tab wide enough for wss://artgins.yunetacontrol.com:1996 is a tab bar with room for one tab. So the view carries it, where there is a whole row for it.

The buttons of that toolbar never shrink. When the row runs out of room, the url is what gives way, cut with an ellipsis, and the whole value stays in the title and the aria-label.

Reading a topic a page at a time

⚠️ with_remote_paging is OFF in every consumer, and must stay off until linking works with a partial topic. A record's link picker is built from the rows the PARENT topic's table holds — build_fkey_options() asks it with get_topic_data, which answers tabulator.getData() — and with paging that is one page. So the picker offers 200 of the 5891 possible parents (the right one is usually not among them), and a record whose parent is not on the loaded page opens with an fkey the form cannot match, which saving then drops. A treedb is a memory database — the backend holds the topic in RAM either way — so paging buys little and costs the links. The machinery below stays, working and tested, for the day linking learns to ask the BACKEND for a parent instead of the sibling table.

Local pagination is not affected and stays on. getData() answers the WHOLE dataset whatever page is on screen, so paginating the DISPLAY is safe; only paginating the FETCH is not. page_size sets the display page for both paths.

A topic table opens sorted by id (since 7.23.143): the pkey, ascending and natural (alphanum, so d2 comes before d10) — normalized records are read by their key. Not when the app passes its own initialSort, and not with with_remote_paging, where a sort in the browser would reorder one page.

C_YUI_TREEDB_TOPICS takes with_remote_paging (off by default) and forwards it to every topic table: the table pulls the page it is showing instead of the host pushing the whole topic down. It needs the SDK's nodes with from / limit (see YUNO_TREEDB.md §5.3).

The page size is generous on purpose (page_size, 200 when paging). A treedb that fits in one page behaves exactly as it did — paginator hidden, every filter seeing every row. Only a topic that does NOT fit pays for paging, and for that one loading it whole was never an option.

Safe against a backend that cannot page: it answers the whole list, which nodes_answer() reads as one page. That is the truth, and it is why the table can ask without knowing what it is talking to.

filterMode: "local" says the plain truth: the header filters and the search box work on the page that is loaded. Same as the tranger browser's Rows card, and for the same reason — the alternative is pushing every filter to the backend and changing what "search" means.

Who does what: the transport belongs to the HOST, so the table asks with EV_REQUEST_PAGE and the answer comes back as EV_PAGE_LOADED, correlated by an id echoed in __md_command__. Read that id flat off the command stack (kw_command.req_id): C_IEVENT_CLI EXTRACTS __md_command__ and pushes it AS the stack's kw, so one level deeper is a level too far — and the symptom is every request timing out with its answer sitting right there.

The promise Tabulator wants is parked in the table (ajaxRequestFunc must RETURN a promise — it is a data source, not an event), with a watchdog, because the link can stay up and an answer still never land. A refresh re-pulls the page the reader is on rather than throwing them back to the first.

What a delete takes with it

A treedb delete is not one thing, and these views delete with force. force on a node does not only remove it: its children are UNLINKED — they survive, loose — and it is cleaned off its parents. So "delete this row" can mean "detach eleven records from their only parent", and the question that used to be asked, are you sure, said none of it.

The confirmation names what is going (the record's key, or how many) and adds a line per thing at stake, each only when there is something at stake — a loose record must not be dressed up as a dangerous one:

  • N children will be UNLINKED, not deleted
  • It will be detached from M parents

Counted off the record the table already has (list_dict fills the hook and fkey columns), so asking costs no round trip. The counting is delete_impact.js, pure and tested, because the shapes are the fiddly part: a hook or fkey value arrives as a list of refs, a dict keyed by id, or a single ref string. A column that is BOTH hook and fkey still counts on both sides, for stores written before the SDK began refusing that pair at schema parse.

In the graph the node-delete popover carries the same two lines, and the unlink popover carries the reassurance that is its whole point: neither record is deleted. Next to a delete button painted the same red, that is not obvious.

Three things this cost, worth knowing before composing any message from keys:

  • yui_shell_confirm_* renders its message as an i18n key, so a composed sentence can never be one. Pass DOM instead — the helper takes it.
  • createElement2 trims text nodes, so a ["span", {}, " "] separator vanishes and the question reads "BorrarDeveloper". Space with CSS.
  • a counted word carries no i18n attribute: yui_shell_show_modal calls refresh_language() on the dialog's content, which re-translates from the key alone — without the count — and puts the plural back over the singular. Nothing is lost, because a dialog with a backdrop never sees a language change.

Editing a topic table in place

A writable scalar is editable in the table, in edition mode (with_inline_edit, default on). Changing one field used to mean opening the record form, changing it, saving and closing.

Which cells, and why not the rest. The schema decides first: only a column flagged writable, and never the pkey — renaming what a record is KEYED by is not a field edit. Then the type: a hook holds children and an fkey IS a link, so both are edited by linking; a dict or a list is a document the form has an editor for; a date cell shows a formatted string over an epoch, so typing into it would write the string. Those stay with the form, one click away on the same row. boolean gets a tick, enum the list of its own values, numbers a number editor.

The write is a partial update with no autolink, and that is the whole safety of it. treedb_update_node() merges (json_object_update), so the fields it does not carry are left alone; autolink is the option that wipes a node's links and rebuilds them from the fkeys the record carries, and on a partial record it reads that as "no parents", detaches the node and answers success. So a cell edit travels as its own event, EV_UPDATE_FIELD, and not as EV_UPDATE_RECORD — that one does send autolink, and may, because the form hands it the whole record with its fkeys in it. See schema_write_options.js for the rule and why each word of it is there.

editable is a function on the column, not a flag: edition mode is toggled on a table that is already built, so the answer has to be asked for at the moment of the click.

A refused write puts the topic back to what the treedb has. Leaving the typed value on screen is tolerable for a form, which stays open on the values it failed with; a cell edited in place would just look saved.

The form stays open until the backend answers (7.23.193). With form_waits_for_answer set — C_YUI_TREEDB_TOPICS sets it — a Save leaves the form open and busy, and EV_CREATE_RECORD / EV_UPDATE_RECORD carry a form_write serial. The host echoes it back in EV_WRITE_DONE (the form closes) or EV_WRITE_REFUSED (it stays open on what was typed), and it must answer EVERY way the write can end: refused before it left (read-only, no session) as well as answered by the backend, or the form waits forever. A host that does not answer leaves the attribute off, and the form closes when the write is published, as before 7.23.193 — whatever the backend says. C_YUI_TREEDB_TOPICS answers them all since 7.25.2: a write sent with no