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.23.45

Published

Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed by wattyzer and the yunetas JS yunos (gui_agent, gui_treedb). The frozen legacy GClass GUI stack (C_YUI_MAIN/

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).

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.

Two maintained lines

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

| Line | Branch | Tag | Layout | Consumed by | How | Status | |------|--------|-----|--------|-------------|-----|--------| | v2 | main | 2.0.0+ | src/ subdir | gui_agent, gui_treedb | local file: dep on the yunetas submodule | active development | | v2 | main | 2.0.0+ | src/ subdir | wattyzer | published npm @yuneta/gobj-ui@^5.0.0 (dist-tag latest) | active development | | v1 | v1 | 1.0.1 | src/ subdir | estadodelaire, hidraulia | published npm @yuneta/gobj-ui@^1.0.1 (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 the in-repo JS yunos (yunos/js/gui_agent, yunos/js/gui_treedb) consume that checkout as a file: dependency (@yuneta/gobj-ui../../../kernel/js/gobj-ui), importing by package specifier (@yuneta/gobj-ui/src/*.js, exports map "./src/*"; the index.js barrel and the vite plugin stay at the package root). wattyzer takes the same line from the registry (since 2026-07-25): the published tarball ships src/, index.js and the vite plugin, so the import specifiers are identical — but library work only reaches it after a npm publish and a range bump on its side. Two consequences worth knowing: a fix cannot be validated in wattyzer before it is released, and wattyzer is the consumer that proves the tarball is complete, not just the checkout.
  • v1 / v1 is the frozen legacy-only stack (the declarative shell is not on this line). It is published to npm; estadodelaire and hidraulia depend on @yuneta/gobj-ui@^1.0.0 from the registry. Land only maintenance fixes here, then npm publish a new 1.x.

All new feature work lands on main/v2.

Usage

# v2 (active): clone yunetas with submodules; the in-repo yunos pick it up via file:
git clone --recurse-submodules <yunetas>
git submodule update --init kernel/js/gobj-ui      # yunetas tracks main/v2

# v2 from the registry (wattyzer, and any out-of-tree consumer)
npm install @yuneta/gobj-ui@^5.0.0

# v1 (frozen): consumers just install the published package
npm install @yuneta/gobj-ui@^1.0.0

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)

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.

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.

  • 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 tooconfig.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.

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 chevron pair, from the lazy tree viewer's own toolbar. 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), view_mode ("tree" default | "text" | "graph"), $container (mounted by the parent).
  • Input events: EV_SET_JSON {json} (replace the whole document; ST_EMPTYST_READY), EV_SUBTREE_LOADED {path, json} (splice a fetched subtree), EV_SUBTREE_ERROR {path, error}, 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). That is the ONLY one, 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).

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

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). A treedb that fits in one page behaves exactly as it did — paginator hidden, every filter seeing every row — so nothing that exists today changes. 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 — and a column can be BOTH hook and fkey, which counts on both sides, because the delete does both things.

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.

Reading a topic table: filters, columns, CSV

C_YUI_TREEDB_TOPIC_WITH_FORM had one global search box over the loaded rows. Three tools join it, each behind its own flag, all on by default:

| attr | what it adds | |---|---| | with_header_filters | a filter box in the header of each column a match means something on | | with_columns_button | a dialog that ticks which columns the table shows | | with_export_button | downloads what the table holds as CSV |

Not every column gets a filter box, on purpose. A hook holds children, a dict holds a subtree, and a date cell shows a formatted string over an epoch number — a text match against the raw value there answers a question nobody asked, so those columns get no box rather than a box that lies. A boolean gets a tristate tick, an enum gets a list of its own values, and an fkey gets a box whose match stringifies the value first, because which rows point at X is the question fkey columns exist to answer and a fkey arrives as a ref string, a list of them or a dict.

The search box and the header filters are separate layers: clearing the search does not silently drop the column filters. The CSV carries what the table HOLDS — the loaded rows, the visible columns, both filters applied, which is what the reader is looking at. It is not the topic: a server-side dump of every node is not something this view can stream, and the button's title says so.

Searching is a user action, so it crosses the FSM (EV_SEARCH) like the rest; it used to call tabulator.setFilter straight from the DOM handler, where the machine trace could not see it.

Read-only treedbs: readonly

C_YUI_TREEDB_TOPICS and C_YUI_TREEDB_GRAPH take a readonly attr; the topics view propagates it to every topic it builds. It is not one more button flag: it is the STATE of the treedb and it beats each with_* flag at once, because a treedb whose tranger the yuno does not master answers every write with

ERROR -1: <yuno>: treedb '<name>' is READ-ONLY, this yuno is not the master of its tranger

(the yuno refuses since SDK 7.13.0), so offering the buttons anyway turns a fact into an error message per click. Ask the yuno which it is with command-yuno id=<yuno> service=<treedb> command=treedb-info, which answers {treedb_name, master, schema_version, topics} — and remember the flag is per TREEDB and is runtime state: a yuno is routinely the master of its treedb_system_schema and a replica of a data treedb it shares.

What readonly takes away: the edition mode, the new / delete / paste buttons, the in-row edit icons, and the write half of the record form's toolbar (copy stays — reading a record includes taking it with you) with the cells not editable. The record form still OPENS: looking is the point of a replica.

In the graph it takes away the edition operation mode, which is the only one that draws the create / delete / link affordances — the mode select stops offering it, and a graph left in edition on a master comes back in reading on a replica (the mode is a persisted preference). The other modes are untouched: panning, zooming and opening a node are reading.

Two implementation notes worth keeping:

  • the decision lives in treedb_write_plan.js (pure, tested), not in five !readonly && with_x expressions — five places to forget the sixth;
  • and the write events are refused as well, in every gclass, with a log_error. Hiding a button is not the same as refusing a write: an event can still arrive from a keyboard path or a form that outlived the flag, and an ignored write is exactly the behaviour this whole change exists to stop.

The graph's viewport toolbar

C_G6_NODES_TREE floats a vertical toolbar over the canvas:

| control | what it does | |---|---| | zoom in / zoom out | one step of scale | | the zoom level | a readout, not a button — 85% | | ── | | | fit | fitView(): the whole graph in the viewport | | 1:1 | zoomTo(1): actual size | | anchor (crosshairs) | pick one element; every zoom then leaves it in the middle | | ── | | | fit to selection | fit, for the part that is selected (edition only, disabled while nothing is) | | ── | | | full screen | the container, not the camera |

Two of those rows are the answer to a real complaint, and the reasoning generalises:

  • 1:1 used to be a house, and the house was the thing people reached for when they wanted the graph back. It never gave it to them: the action is zoomTo(1), which sets the scale and leaves the camera where it was, so from a corner of a large graph it answered with the same corner at 100%. A house means the initial extent in a map and the starting view in an editor — never a scale — and this one sat directly under fit, so the pair read as two ways to do one thing. Actual size is written in every editor that offers it, because there is no glyph anybody recognises for it.
  • the zoom level is shown because 1:1 is a jump to a number, and a jump to a number is only meaningful next to the number you are on.
  • fit to selection wears the fit icon with a marked object inside it, drawn rather than borrowed: the two sit next to each other and are the same action at two scopes, so they have to read as a family. It appears only in edition — a button that can never be enabled is furniture, not a control — and is disabled while nothing is selected. fitView() has no subset form, so the bounds are measured off the elements and the zoom is clamped to the graph's own zoomRange, the only limit that is not invented here. One card filling the view is not a bug: that is what zooming to it means.

The separators are gaps, not lines: every item already carries a hairline against its neighbour, so one more line would not group anything. Full screen is behind the second one because it is a window control that happens to live in a camera toolbar.

Both toolbars (this one and the edit one) follow the theme. They used to be pinned to a light background in both themes, with the icon colour pinned dark so it survived that — two light islands over a dark canvas.

New keys for consumers: actual size, zoom level, zoom to selection (all tooltips, so a host that has not defined them shows the key on hover and nothing else breaks).

The anchor: one element the camera holds

The same crosshairs button is in all three graphs' toolbars, drawn once in yui_graph_camera.js. Why it exists: a graph that FITS on screen is unreadable at the zoom that makes it fit — one topic's schema fits at 37%, where every card is grey texture — and the zoom that makes it readable does not fit. So the useful view is always a fraction of the document, and WHICH fraction was nobody's decision: 1:1 translated to the layout's origin, a corner with nothing in it, and the reader then hunted for the node they had been looking at.

Three states, because two could not say what a press does: off, arming (the attention colour, and the pointer over the canvas turns to a crosshair) and on (pressed). The anchored card carries a dashed amber outline — dashed because in the treedb graph a solid amber ring already means a find match and blue means the selection, so a third solid ring would be a third thing to learn in one channel.

Two things a host does not have to think about but a maintainer does:

  • While it is arming, drag-element is swapped OUT with setBehaviors(). With it in, a click that drifts two pixels — which is every click a hand makes — becomes a drag, G6 fires no node:click, and the pick silently does not happen.
  • The camera move is POSTED to the gclass, not made in the handler: a translate issued inside G6's click dispatch or its aftertransform is swallowed. And the translate itself is a closed form, T = (canvasCentre - nodeWorld) * zoom, read off G6's own getTranslateOptions()focusElement() and translateBy() compute the right offset, resolve, and do not move the camera at all.

New keys for consumers: anchor view, click the element to centre on, centred: click to release.

Moving the cards, and folding without losing your place

The JSON graph and the gobj tree take drag-element: a card can be dragged. The position is deliberately NOT kept — both are rebuilt from their source on every refresh, fold and layout change, and neither is a document of its own to save it to — and it is worth having anyway, because pulling two cards apart to read the lines between them is most of what a reader wants from a graph.

Folding no longer moves anything. It used to re-pack the tree: five surviving cards would all slide 240px sideways for a fold that removed nothing they could see, and the card under the finger slid out from under it. Two halves:

  • the card you clicked is read before the rebuild and put back afterwards (yui_graph_place_at(), the anchor's arithmetic with the target point left free);
  • and the others stay because a fold leaves a phantom child — an invisible node of exactly the width its children had, in the place they had in the order. Reserving the width as a NUMBER does not work: the layout centres a parent over its children, so a lump appended at the end moves every sibling by half of it.

collapse all reserves nothing on purpose: it is asking for a COMPACT drawing.

The gobj tree remembers how you left it — layout, zoom, camera, folds and anchor — in localStorage, keyed by the gobj's name. Not in a persistent attr because only a SERVICE can save those and this gclass is hosted as a child, and because how somebody left THEIR tree is a fact about that browser. The layout and the folds are restored BEFORE the first build, since they decide what is built; the camera after, and once only, or every fold would drag the reader back.

The graphs on a touch screen

The three G6 graphs (C_G6_NODES_TREE under the treedb view, C_YUI_JSON_GRAPH, C_YUI_GOBJ_TREE_JS) have always DRAWN correctly on a phone. What they could not be was operated on one, for reasons that were structural rather than cosmetic — and none of them is visible in the CSS:

  • There is no wheel on a phone, and zoom-canvas binds the wheel and nothing else, so the only zoom was the toolbar's +/. G6 ships a pinch recogniser, but trigger: ['pinch'] REPLACES the wheel (its bindEvents is an if/else), and its PinchHandler keeps its instance and callbacks in statics — with two graphs on a page the second registers against the first one's emitter, so pinching one zooms both and pinching the other does nothing. g6_touch_gestures.js recognises the gesture per graph and registers a zoom-canvas that keeps the wheel, the same pattern g6_drag_canvas_touch.js already uses for drag-canvas. Every graph gets it with no change to its behaviors list.
  • G6 does not read the DOM's contextmenu event. Its BehaviorController synthesises the event from pointerdown with button === 2, so the context menu was a right click and only a right click, whatever the browser does with a long press. The same module gives it a long press (500ms, 10px of slop) that re-emits G6's own forwarded event under the name the plugin listens for — so getItems(e) sees exactly what a right click gives it, including the port under the finger.
  • One finger has to serve three commands, so the press is arbitrated — at the RELEASE (press_arbiter.js, pure and tested): moved → drag; still and let go quickly → the element's own action; still and held past 500ms → the context menu — with a 15ms haptic tick at the 500ms mark, which is a NOTICE and not the decision (a finger that buzzes and then carries the node away still gets its drag). Firing the menu on a TIMER instead cannot arbitrate anything, because at the moment it fires the gesture is not over: it opened while drag-element was already carrying the node, so one press meant both things and the menu sat over a card running away underneath it. Two more things the same press was doing, both fixed with it: click is not taken from the DOM either (@antv/g's onPointerUp synthesises one), so the press that opened the menu went on to click the node it opened the menu on — the click handlers ask consume_long_press_click() first; and the BROWSER's own menu, which opens while the finger is still down, is refused for as long as there is a finger on the glass.
  • The browser was taking the gesture outright. G6 puts touch-action: none on its canvas and nothing on its HTML nodes, which are ordinary DIVs over it — so a drag that started on a card was a page scroll: two pointermoves through, then pointercancel, and the node stopped dead about 20px in while the page slid. .graph-container refuses those gestures whole now; the panels and the context menu keep touch-action: auto, so a finger still scrolls what is meant to scroll.

Everything a finger has to LAND on is sized off (pointer: coarse), which means a mouse sees no change at all: node resize handles become a 14px mark in a 44px box and drop to the four corners (eight fingertip-sized boxes around a 90px node overlap into one blob, and a corner resizes both axes); node properties and delete node go from two 28px circles 4px apart — one fingertip covered both, with the destructive one underneath — to 44px and 12px apart; a port's hit area stops being a flat +4 in world units (a different target at every zoom, and 5 screen px at the 50% a phone lands on after fit) and becomes a screen measurement converted to world; popover controls and context-menu rows get a 44px floor at 16px, under which iOS Safari zooms the page on focus and never zooms back.

And the two floating toolbars fold. They are drawn inside the canvas, one on each edge: on a 356px-wide phone canvas they took a third of the drawing area and stood on top of the nodes. Under 480px of container — measured on the container, not the window, because the same graph is a full page in one app and a card in a column in another — they collapse behind a single , and the edit strip is removed rather than emptied (an empty toolbar is still a card with a border sitting on the graph). Above that width nothing changes.

New keys for consumers: show toolbar, hide toolbar.

Multi-selection reaches a finger through a MODE, not through a gesture (7.23.11). Both halves of it — shift+click to add a card, shift+drag for the rubber band — hang off a key a phone does not have, and there is no spare gesture to give them: G6 binds panning and the band to the same plain drag, so one of the two has to stand aside. The edit toolbar carries a selection mode toggle (the dashed marquee, next to +): while it is on, a tap picks a card and a drag on the background draws the band, and panning is what stands aside. It looks pressed while it is on (7.23.12, pressed_state, an inverted neutral chip) rather than taking one of the palette's colours: each of those names a KIND of action, and 7.23.11 borrowed undo/redo's violet for it — which put the same colour on two neighbouring buttons for two different reasons, and carried the whole state change in the hairline of an outline glyph. It is deliberately a button and not a heuristic — the toolbar says which of the two the graph is listening for, and turning it off gives the camera back. It is not device-specific: the same button spares a desktop reader the key. Leaving edition turns it off, and it is not persisted.

New key for consumers: selection mode.

The two selects of the toolbar speak the app's language (7.23.13). They rendered their raw names — reading, edition, dagre, manual — in every language, because neither went through t() at all: not a missing key, a missing call. The label is translated and carries its own data-i18n, so it follows a language change; the option's value is now set EXPLICITLY, because an <option> with no value answers with its own text and a translated label would have sent "Edición" to the FSM as the mode to enter.

New keys for consumers: reading, operation, writing, edition, manual, dagre, antv-dagre, d3-force, force-atlas2. A host that adds a layout of its own gets its name untranslated, which is what all of them got until now.

Tabs opened at runtime, and the two decisions their url costs

yui_tab_routes.js. A workspace whose tabs are opened by the operator — /<ws>/<home>/<id>, with whatever the tab is showing below it — pays for that url twice, and both apps in this family learned the same two lessons, one of them the hard way.

yui_tab_split_subpath(subpath){id, tail}. On a cold load the tab's route does not exist yet: it is registered when the tab is opened, so a reload on /<ws>/<home>/<id>/<tail> resolves only as far as the workspace home and the shell hands the WHOLE rest over as the subpath — <id>/<tail>, not <id>. Reading all of it as the id matches nothing, and an app that then falls back to its first tab answers a reload with somebody else's default. It hides well: a bare tab route survives, because there the subpath IS the id, so only the deep case breaks and only for whoever reloads on one.

Only the id segment is decoded. These ids are composite (<node>+0x1F+ <yuno>, <conn>+0x1F+<treedb>) and reach the url percent-encoded, so decoding the whole tail first would turn an encoded slash inside an id into a separator and cut it in two.

yui_tab_position_plan(prev_base, base, subpath, remembered){record, replay}. A tab's nav item is a FIXED route — yui_shell_set_submenu() registers it, and that route is where the view is mounted — so the position inside a tab cannot travel in the item and has to be replayed when the tab is entered again. "Entered again" is the whole subtlety: arriving at the root of the tab you were ALREADY in is the way OUT of whatever was open, and replaying the position there would make that button do nothing.

What is NOT here: the wiring. One host restores on its transport's EV_ON_OPEN, another normalizes the route as it arrives, and both are right for what they know about when their tabs become real. These are the decisions, not the plumbing — which is also why they are pure and tested rather than three lines inside an action.

Selecting several nodes, and moving them together

In edition mode the graph has a real selection, not just "the node you clicked":

| gesture | what it does | |---|---| | click a node | selects it and opens it: resize handles, ports, popovers | | shift + click | adds that node to the selection, or takes it out | | shift + drag on the canvas | rubber band: the selection becomes what it enclosed | | the selection mode button | makes those two the PLAIN gestures — a tap picks, a canvas drag is the band — for as long as it is on (panning stands aside meanwhile) | | ctrl/cmd + A | every node | | the fit-to-selection button | puts the viewport on what is selected | | drag any selected node | moves the whole selection, as one undo | | Delete / Backspace | deletes the selection, after a confirmation that counts what it takes | | Esc, or a click on the canvas | clears it |

The keys reach the graph only while the graph has focus — G6 gives its canvas a tabIndex of its own — which is what keeps ctrl+A inside the find box a selection of the TEXT and not of every node: the focus is in the input, and the input is not inside the canvas. They arrive as EV_KEY_DOWN and the action decides, so a key is as visible in the machine trace as a click.

Three decisions are worth knowing, because each one is where this could have gone wrong:

  • G6's selected element state IS the selection. drag-element decides what a drag moves by asking the graph for it (getElementDataByState('node', 'selected')), so a set kept anywhere else would be a second truth the drag never consults — the ring would say five and one would move. It also batches the move, so a group drag is one history entry rather than one per node.

  • The ring is painted into the card's own html, and it had to be. A state style paints on a node's KEY SHAPE, and every node here is an html node whose key shape is a DOM element — the same reason the amber highlight had never appeared before 7.3.0. Selecting with brush-select and nothing else would have selected correctly and shown nothing. The ring is blue and drawn OUTSIDE the amber halo, so a node that is both a find match and selected wears both; one function composes them (ring_shadow), because before it each repaint wrote its own flag and erased the other's.

  • The gesture is G6's, the result is an event. brush-select gets an onSelect that sends EV_BRUSH_SELECT with the ids, and the action does the work — so a marquee shows up in the machine trace like every other action. Shift+click is not G6's click-select at all: this gclass already owns EV_NODE_CLICK, and adding a second selection owner outside the FSM is how the two end up disagreeing. The band comes from g6_brush_select_owned.js, a brush-select under our own id with ONE half removed: the built-in also rewrites the selected state of every node and every edge on each canvas click, behind the gclass and outside the history pause with which it sets and clears the selection — a recorded command whose before and after are identical, which is a lit Save on a graph nobody has touched (7.23.10).

Panning gives way while Shift is held AND while the pointer is on a card (drag-canvas takes an enable predicate) — the first because the canvas would otherwise pan under the rubber band, the second because it would otherwise pan under the node being dragged. That second half is G6's own default, and an enable REPLACES the default rather than adding to it: while it was missing, a drag moved the card at twice the pointer and slid the whole graph underneath it (7.23.10).

Undo, Redo and Save are the history plugin, and it follows the MODE. It is installed on entering edition and removed on leaving it — including when edition is reached through the mode selector on a graph whose data is already loaded, which is the ordinary way in. Before 7.23.10 it was installed at the arrival of the last topic of the load and only if the graph was in edition right then, so that ordinary way left the graph with dead Undo/Redo buttons and a history_pause() nothing answered, while Save still lit on its own.

A delete says what it takes, whether it is one or twenty. The Delete key asks the same question the per-node delete icon asks, built by the same function: the record's key when there is one, the count when there are more, and then the two lines that are actually at stake — N children will be UNLINKED, not deleted and it will be detached from M parents — where over a set the numbers are the sums. These views delete with force, so an operator pressing Delete over twelve cards has to read eleven detached children, not "are you sure". It needs no new keys: the sentence is the one 7.10.0 already defined. The question has no icon to hang off, so it is asked in the middle of the graph it is about.

A marquee selects, it does not open. Even when it encloses exactly one node, the handles and ports stay away: _selected_node_id means the node opened for editing, and only a click sets it. Everything that hangs off a single node reads that field, so a multiple selection puts all of it away by construction rather than by a check in twenty places.

Finding a node in the graph

C_YUI_TREEDB_GRAPH carries a find box in the middle of its toolbar. It matches the term against the node's **l