@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-jsrose to^7.26.3. The library passes its markup throughhtml_markup(), which first ships in that gobj-js. A consumer locked on an older gobj-js getsERESOLVE: raise both in the same change. See the CHANGELOG entry.BREAKING (7.23.172): the peer floor of
@yuneta/gobj-jsrose 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-glpeer floor rises to^6.4.1, which is whereDOM.sanitizestops 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 asimport * as maplibregl; bundling the map with Vite 8 also means emitting maplibre's worker + shared chunk yourself and pointingsetWorkerUrl()at them (Vite cannot statically follow v6's dynamic worker URL, and a.mjsworker is refused when the host serves it asapplication/octet-stream). The test-app'svite.config.js(maplibre_worker_assets) +src/main.jsare 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 throughsrc/yui_theme.js(yui_theme_now()/yui_is_dark()/yui_watch_theme(gobj), all barrel-exported — the watcher translates the DOM mutation and the OSprefers-color-schemeflip intoEV_THEME); reflow uses each component's ownResizeObserver. 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
titleis now an i18n KEY, rendered withdata-i18nso it re-translates on language change. Pass the key, nevert(key), and never compose data into it — the DATA half (a topic/service/marker name) travels in the newtitle_prefixattr/opt, shown before the title and never translated (C_YUI_WINDOW,yui_shell_show_modal, the dock chip). The oldtitle_fn/retitle_modalhooks are removed.BREAKING (4.0.0): minimize requires a window manager.
C_YUI_WINDOWpaints its minimize button only when the window has amanager(C_YUI_WINDOW_MANAGER): minimize means "send to the dock", and without a manager there is nowhere to send it — soshowMinis 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 itsis-shadedCSS 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_WINDOWwhose host names no$parentmounts 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 ondocument.bodyonly while there is no shell. It used to be the body always, and since.yui-shellisposition: 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$parentthe 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'sEV_FOCUS_WINDOW, one without a dock through its ownEV_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_CHANGEDto every gobj that asked withyui_shell_watch_changes()-- everyC_YUI_WINDOWand 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 wasdocument.bodyat 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 arole="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
responsivewith noinline_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 takestop: 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,modalandkeyboardwere 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-labelledbyon the two halves,title_prefixandtitle, so the name changes language; under aheaderof 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:focusis 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 ($parentin 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,trueby default) when the focus is in it, the way its ✕ does:EV_CLOSE_WINDOW, soEV_WINDOW_TO_CLOSEstill vetoes or asks. Not an Escape that what it was typed in took first: a handler that calledpreventDefault()(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), atextareaor acontenteditable(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 withkeyboard: false; the key then goes on to the shell;- a
modalone 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: amanagergiven 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 hereThe 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:noneon 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_closeruns (itsbefore_closeis 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
stayroute parked under one, are dead once it stops: a Back onto one closes nothing. The stopped shell does not take them off -- ahistory.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 withuse_hashtakes them off with ONEhistory.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): alocation.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 onehistory.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'scurrententrychange. 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 ownpopstate/hashchangelistener 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'ssessionStoragefor 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). Ahistory.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") whoseon_closecallsyui_shell_unpark_route()has the URL rewritten in place to the resting route, not taken back (7.26.48): a singleback()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 OWNhistory.back()has the same defect: callyui_shell_unpark_route()instead. A question open at the stop answers what Escape answers --falsefor a yes/no or a danger question,"cancel"for yes/no/cancel --; ayui_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 itskeydownlistener until arelease()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 /
mainis the active development line: the declarative shell (legacy-stack-free since3.0.0). It is embedded as a git submodule in yunetas atkernel/js/gobj-ui, and every app takes it from the npm registry (since 2026-08-03 there is nofile:consumer left). Apps import by package specifier (@yuneta/gobj-ui/src/*.js, exports map"./src/*"; theindex.jsbarrel and the vite plugin stay at the package root), and the published tarball shipssrc/,index.jsand the vite plugin. So library work reaches an app only after anpm publishand a range bump on its side (npm run deploy-rounddoes the round).test-app/is the one exception: it consumes the working tree (file:..), which is what it is for. - v1 /
v1is 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, thennpm publisha new1.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@legacyEdit 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 Firefoxnpm 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 … */ ]}
]
}
}projectionis aC_YUI_NAVrender config (so cards/tabs/vertical/ icon-bar/backbar andshow_onall work unchanged) in two modes:indexwhen the node is the tip of the path — the projection IS the page — andchromewhen 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
chromestrip lists that node's children, and its backbar goes back to that node's route (back_route = my_route). A branch that declares nochrometherefore 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 samechromeon every branch that can have a tip below it — that is whattest-app's/cardsdoes, and what makes its ← hierarchical.contentandchildrenare 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 viayui_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:indexandchromeproject a node's CHILDREN;pathprojects 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": truenext to itsid): 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.19it 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) andEV_DRAWER_CLOSE_REQUESTED(the node closes its own drawer projection: the shell'syui_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 inST_OFF.nav_mode— the three shapes as one runtime knob. The two bullets above describe what a tree declares;nav_modeis 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 declaredverticalchrome comes back asvertical, not as the tabs a canonical "stacked" shape would have imposed. Theindexprojection 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/adminas a breadcrumb and/alarmsas a backbar;test-app's node lab cycles all three on the live tree.chrome_depthcaps 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
childrenattr is fed to the sameEV_ADD_NODEthe 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_SHELLmounts a view withgobj_create()(a plain child) andC_YUI_NODEwithgobj_create_pure_child(). The flag decides whether a gclass that consultsgobj_is_pure_child()sends its output event straight to the parent or publishes it. Today nothing consults it on a view (onlyc_ievent_cliandc_timerdo, 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 behindis-hiddencomes up 0×0.register_c_yui_json()auto-registersC_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: preon amax-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 bemax-contentwide or the container reports no overflow and the tail of every long line is unreachable. - The graph body carries a definite
height(24rem, withflex: 1 1 autoso a constrained host still wins). Notmin-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 inST_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), plusEV_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" untilEV_SUBTREE_LOADEDorEV_SUBTREE_ERRORarrives. 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_GRAPHsince7.25.21:i18n: "no session", or the transport's refusal aserror). 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_STATEor the shell'sEV_CONNECTION_STATE, both{connected: false}), answers each onei18n: "the connection dropped"(since7.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, asC_YUI_TREEDB_TOPIC_WITH_FORMdoes for its schema, cell and table viewers since7.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);erroris text, drawn as it came (a backend's comment);i18nis 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 namei18nis the one avalidate-localesscan looks for, so the key stays visible to it.by_designsays the host CANNOT, which is not a failure: the viewer logs it as a WARNING, and anything else as an ERROR. Before7.25.20the form sentt(...), 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_PATHis 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'sEV_JSON_ITEM_CLICKEDstops here (7.21.0forwarded it and broke exactly that way); a host that wants node clicks mountsC_YUI_JSON_GRAPHitself.Internal (DOM → FSM):
EV_TOGGLE_NODE,EV_EXPAND_COLLAPSED,EV_SEARCH,EV_EXPAND_ALL,EV_COLLAPSE_ALL,EV_COPY_ALL. Every kw carries only apathstring — 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 insidet()inview_label()— nevert(VIEWS[i].key), which novalidate-localescan see (that shipped once, in7.20.0).Paths use the kernel delimiter (backtick) and index arrays numerically, so a path emitted by the viewer round-trips through
kw_find_pathon 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_pagingis 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 withget_topic_data, which answerstabulator.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_sizesets 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.createElement2trims text nodes, so a["span", {}, " "]separator vanishes and the question reads "BorrarDeveloper". Space with CSS.- a counted word carries no
i18nattribute:yui_shell_show_modalcallsrefresh_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
